diff --git a/.gitignore b/.gitignore index 2df19f0..60ecff6 100755 --- a/.gitignore +++ b/.gitignore @@ -17,5 +17,9 @@ Thumbs.db *.mca *.mcr +# Agent +AGENTS.md +.opencode/ + # Logs *.log diff --git a/Cargo.lock b/Cargo.lock index 42a4cb7..f939ad1 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -1110,6 +1110,7 @@ dependencies = [ "objc2-foundation 0.2.2", "parking_lot", "percent-encoding", + "pollster", "profiling", "raw-window-handle", "static_assertions", @@ -1117,6 +1118,7 @@ dependencies = [ "wasm-bindgen-futures", "web-sys", "web-time", + "wgpu", "winapi", "windows-sys 0.59.0", "winit", @@ -1661,6 +1663,18 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows", +] + [[package]] name = "gpu-descriptor" version = "0.3.2" @@ -2831,6 +2845,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + [[package]] name = "prettyplease" version = "0.2.37" @@ -3026,6 +3046,12 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3578,9 +3604,11 @@ dependencies = [ "serde", "serde-big-array", "tempfile", + "terrafier-biome-db", "terrafier-fastanvil", "terrafier-nbt", "terrafier-noise", + "terrafier-palette-compress", "thiserror 2.0.18", ] @@ -3601,10 +3629,12 @@ dependencies = [ "anyhow", "eframe", "egui", + "egui-wgpu", "env_logger", "log", "rfd", "terrafier-core", + "wgpu", ] [[package]] @@ -3810,9 +3840,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "uds_windows" @@ -4221,6 +4251,7 @@ dependencies = [ "document-features", "js-sys", "log", + "naga", "parking_lot", "profiling", "raw-window-handle", @@ -4268,13 +4299,16 @@ dependencies = [ "android_system_properties", "arrayvec", "ash", + "bit-set", "bitflags 2.11.1", + "block", "bytemuck", "cfg_aliases", "core-graphics-types", "glow", "glutin_wgl_sys", "gpu-alloc", + "gpu-allocator", "gpu-descriptor", "js-sys", "khronos-egl", @@ -4289,6 +4323,7 @@ dependencies = [ "ordered-float", "parking_lot", "profiling", + "range-alloc", "raw-window-handle", "renderdoc-sys", "rustc-hash 1.1.0", @@ -4298,6 +4333,7 @@ dependencies = [ "web-sys", "wgpu-types", "windows", + "windows-core", ] [[package]] diff --git a/bench/benches/world.rs b/bench/benches/world.rs index 0dadbbf..372e072 100755 --- a/bench/benches/world.rs +++ b/bench/benches/world.rs @@ -3,8 +3,7 @@ use std::sync::Arc; use criterion::{Criterion, black_box, criterion_group, criterion_main}; use terrafier_core::io::export::render_to_image; -use terrafier_core::model::brush::SymmetricBrush; -use terrafier_core::model::terrain::Terrain; +use terrafier_core::model::types::{SymmetricBrush, Terrain}; use terrafier_core::model::world::World; use terrafier_core::ops::operations::{HeightOperation, Operation, PaintOperation}; diff --git a/cli/src/cmds/export.rs b/cli/src/cmds/io/export.rs similarity index 100% rename from cli/src/cmds/export.rs rename to cli/src/cmds/io/export.rs diff --git a/cli/src/cmds/import.rs b/cli/src/cmds/io/import.rs similarity index 100% rename from cli/src/cmds/import.rs rename to cli/src/cmds/io/import.rs diff --git a/cli/src/cmds/io/mod.rs b/cli/src/cmds/io/mod.rs new file mode 100644 index 0000000..8e3083f --- /dev/null +++ b/cli/src/cmds/io/mod.rs @@ -0,0 +1,3 @@ +pub mod export; +pub mod import; +pub mod render; diff --git a/cli/src/cmds/render.rs b/cli/src/cmds/io/render.rs similarity index 100% rename from cli/src/cmds/render.rs rename to cli/src/cmds/io/render.rs diff --git a/cli/src/cmds/mod.rs b/cli/src/cmds/mod.rs index 0cdde8c..cb967b0 100755 --- a/cli/src/cmds/mod.rs +++ b/cli/src/cmds/mod.rs @@ -1,5 +1,7 @@ -pub mod export; -pub mod import; +mod io; +pub use io::export; +pub use io::import; +pub use io::render; + pub mod info; pub mod new; -pub mod render; diff --git a/core/Cargo.toml b/core/Cargo.toml index fd98b21..22ff9d8 100755 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -14,6 +14,8 @@ log.workspace = true terrafier-nbt = { path = "../crates/nbt" } terrafier-fastanvil = { path = "../crates/fastanvil" } terrafier-noise = { path = "../crates/noise" } +terrafier-biome-db = { path = "../crates/biome-db" } +terrafier-palette-compress = { path = "../crates/palette-compress" } serde-big-array.workspace = true diff --git a/core/src/io/binary.rs b/core/src/io/binary.rs deleted file mode 100755 index fa5148a..0000000 --- a/core/src/io/binary.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! Binary serialization for Terrafier world model. -//! -//! Uses `bincode` for compact, fast serialization. -//! File extension: `.tfwb` (Terrafier World Binary) - -use std::path::Path; - -use crate::model::world::World; - -#[derive(thiserror::Error, Debug)] -pub enum BinaryError { - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - #[error("Bincode serialize error: {0}")] - Serialize(String), - #[error("Bincode deserialize error: {0}")] - Deserialize(String), - #[error("Validation failed: {0}")] - Validation(String), -} - -pub type Result = std::result::Result; - -/// Save a `World` to a binary file. -pub fn save_binary(world: &World, path: &Path) -> Result<()> { - let bytes = bincode::serialize(world).map_err(|e| BinaryError::Serialize(e.to_string()))?; - - std::fs::write(path, &bytes)?; - Ok(()) -} - -/// Load a `World` from a binary file. -pub fn load_binary(path: &Path) -> Result { - let bytes = std::fs::read(path)?; - let world: World = - bincode::deserialize(&bytes).map_err(|e| BinaryError::Deserialize(e.to_string()))?; - Ok(world) -} - -/// Validate a binary world file by loading and discarding. -pub fn validate_binary(path: &Path) -> Result<()> { - if !path.exists() { - return Err(BinaryError::Validation(format!( - "File not found: {}", - path.display() - ))); - } - let _world = load_binary(path)?; - Ok(()) -} diff --git a/core/src/io/layer_export.rs b/core/src/io/layer_export.rs deleted file mode 100644 index 0279b7c..0000000 --- a/core/src/io/layer_export.rs +++ /dev/null @@ -1,317 +0,0 @@ -//! Layer export system — applies layer data to modify blocks during Minecraft export. - -use crate::coords::TILE_SIZE; -use crate::model::layers::{ - LAYER_BIOME, LAYER_CAVES, LAYER_FROST, LAYER_RESOURCES, LAYER_RIVER, LAYER_TREES, -}; -use crate::model::tile::{LayerBuffer, Tile}; - -/// A layer exporter knows how to modify blocks based on layer data. -pub trait LayerExport: Send + Sync { - fn layer_id(&self) -> u32; - fn modify_block( - &self, - tile: &Tile, - local_x: usize, - local_z: usize, - y: i32, - surface_y: i32, - block_name: &str, - ) -> Option<&'static str>; - - /// Optional biome override for this position. - fn biome_name(&self, tile: &Tile, local_x: usize, local_z: usize) -> Option<&'static str> { - let _ = (tile, local_x, local_z); - None - } -} - -/// Apply all layer exporters in order. The first exporter that returns a replacement wins. -pub fn apply_layers<'a>( - tile: &Tile, - local_x: usize, - local_z: usize, - y: i32, - surface_y: i32, - block_name: &'a str, - layer_exporters: &[&dyn LayerExport], -) -> &'a str { - for exporter in layer_exporters { - if let Some(replacement) = - exporter.modify_block(tile, local_x, local_z, y, surface_y, block_name) - { - return replacement; - } - } - block_name -} - -/// Resolve the biome name from layer exporters, falling back to plains. -pub fn biome_name( - tile: &Tile, - local_x: usize, - local_z: usize, - layer_exporters: &[&dyn LayerExport], -) -> &'static str { - for exporter in layer_exporters { - if let Some(name) = exporter.biome_name(tile, local_x, local_z) { - return name; - } - } - "minecraft:plains" -} - -// --------------------------------------------------------------------------- -// Built-in layer exporter implementations -// --------------------------------------------------------------------------- - -pub struct CavesLayerExport; -impl LayerExport for CavesLayerExport { - fn layer_id(&self) -> u32 { - LAYER_CAVES - } - - fn modify_block( - &self, - tile: &Tile, - local_x: usize, - local_z: usize, - y: i32, - surface_y: i32, - block_name: &str, - ) -> Option<&'static str> { - let idx = local_z * TILE_SIZE + local_x; - let data = tile.get_layer_data(LAYER_CAVES)?; - if let LayerBuffer::Nibble(nibbles) = data { - let byte = nibbles[idx / 2]; - let value = if idx.is_multiple_of(2) { - byte >> 4 - } else { - byte & 0xF - }; - if value > 0 && y < surface_y && y >= surface_y - 12 && y > -60 { - match block_name { - "minecraft:stone" - | "minecraft:deepslate" - | "minecraft:dirt" - | "minecraft:grass_block" - | "minecraft:sand" - | "minecraft:sandstone" - | "minecraft:gravel" => Some("minecraft:air"), - _ => None, - } - } else { - None - } - } else { - None - } - } -} - -pub struct RiverLayerExport; -impl LayerExport for RiverLayerExport { - fn layer_id(&self) -> u32 { - LAYER_RIVER - } - - fn modify_block( - &self, - tile: &Tile, - local_x: usize, - local_z: usize, - y: i32, - surface_y: i32, - block_name: &str, - ) -> Option<&'static str> { - let idx = local_z * TILE_SIZE + local_x; - let data = tile.get_layer_data(LAYER_RIVER)?; - if let LayerBuffer::Byte(bytes) = data { - let value = bytes[idx]; - if value > 0 { - let depth = value as i32; - if y <= surface_y && y > surface_y - depth { - if y >= surface_y - 1 { - Some("minecraft:water") - } else { - match block_name { - "minecraft:stone" - | "minecraft:deepslate" - | "minecraft:dirt" - | "minecraft:grass_block" - | "minecraft:sand" - | "minecraft:sandstone" - | "minecraft:gravel" => Some("minecraft:air"), - _ => None, - } - } - } else { - None - } - } else { - None - } - } else { - None - } - } -} - -pub struct FrostLayerExport; -impl LayerExport for FrostLayerExport { - fn layer_id(&self) -> u32 { - LAYER_FROST - } - - fn modify_block( - &self, - tile: &Tile, - local_x: usize, - local_z: usize, - y: i32, - surface_y: i32, - _block_name: &str, - ) -> Option<&'static str> { - let idx = local_z * TILE_SIZE + local_x; - let data = tile.get_layer_data(LAYER_FROST)?; - if let LayerBuffer::Bit(bits) = data { - let word = bits[idx / 64]; - let bit = (word >> (idx % 64)) & 1; - if bit == 1 && y == surface_y { - if _block_name == "minecraft:water" { - Some("minecraft:ice") - } else if _block_name == "minecraft:grass_block" { - Some("minecraft:snow_block") - } else { - None - } - } else { - None - } - } else { - None - } - } -} - -pub struct TreesLayerExport; -impl LayerExport for TreesLayerExport { - fn layer_id(&self) -> u32 { - LAYER_TREES - } - - fn modify_block( - &self, - tile: &Tile, - local_x: usize, - local_z: usize, - y: i32, - surface_y: i32, - block_name: &str, - ) -> Option<&'static str> { - let idx = local_z * TILE_SIZE + local_x; - let data = tile.get_layer_data(LAYER_TREES)?; - if let LayerBuffer::Byte(bytes) = data { - let value = bytes[idx]; - if value > 0 - && block_name == "minecraft:air" - && y > surface_y - && y <= surface_y + value as i32 - { - Some("minecraft:oak_log") - } else { - None - } - } else { - None - } - } -} - -pub struct ResourcesLayerExport; -impl LayerExport for ResourcesLayerExport { - fn layer_id(&self) -> u32 { - LAYER_RESOURCES - } - - fn modify_block( - &self, - tile: &Tile, - local_x: usize, - local_z: usize, - y: i32, - _surface_y: i32, - block_name: &str, - ) -> Option<&'static str> { - let idx = local_z * TILE_SIZE + local_x; - let data = tile.get_layer_data(LAYER_RESOURCES)?; - if let LayerBuffer::Nibble(nibbles) = data { - let byte = nibbles[idx / 2]; - let value = if idx.is_multiple_of(2) { - byte >> 4 - } else { - byte & 0xF - }; - if value > 0 && block_name == "minecraft:stone" && y > -60 { - let ore = match value { - 1 => "minecraft:coal_ore", - 2 => "minecraft:iron_ore", - 3 => "minecraft:copper_ore", - 4 => "minecraft:gold_ore", - 5 => "minecraft:redstone_ore", - 6 => "minecraft:lapis_ore", - 7 => "minecraft:diamond_ore", - 8 => "minecraft:emerald_ore", - _ => return None, - }; - Some(ore) - } else { - None - } - } else { - None - } - } -} - -pub struct BiomeLayerExport; -impl LayerExport for BiomeLayerExport { - fn layer_id(&self) -> u32 { - LAYER_BIOME - } - - fn modify_block( - &self, - _tile: &Tile, - _local_x: usize, - _local_z: usize, - _y: i32, - _surface_y: i32, - _block_name: &str, - ) -> Option<&'static str> { - None - } - - fn biome_name(&self, tile: &Tile, local_x: usize, local_z: usize) -> Option<&'static str> { - let idx = local_z * TILE_SIZE + local_x; - let data = tile.get_layer_data(LAYER_BIOME)?; - if let LayerBuffer::Byte(bytes) = data { - Some(match bytes[idx] { - 0 => "minecraft:plains", - 1 => "minecraft:desert", - 2 => "minecraft:forest", - 3 => "minecraft:taiga", - 4 => "minecraft:swamp", - 5 => "minecraft:snowy_plains", - 6 => "minecraft:jungle", - 7 => "minecraft:badlands", - 8 => "minecraft:ocean", - 9 => "minecraft:river", - 10 => "minecraft:mushroom_fields", - _ => "minecraft:plains", - }) - } else { - None - } - } -} diff --git a/core/src/io/layer_export/biome_res.rs b/core/src/io/layer_export/biome_res.rs new file mode 100644 index 0000000..fb729b1 --- /dev/null +++ b/core/src/io/layer_export/biome_res.rs @@ -0,0 +1,106 @@ +use std::sync::OnceLock; + +use terrafier_biome_db::BiomeDb; + +use crate::coords::TILE_SIZE; +use crate::io::layer_export::LayerExport; +use crate::model::layers::{LAYER_BIOME, LAYER_RESOURCES}; +use crate::model::tile::{LayerBuffer, Tile}; + +pub struct ResourcesLayerExport; +impl LayerExport for ResourcesLayerExport { + fn layer_id(&self) -> u32 { + LAYER_RESOURCES + } + + fn modify_block( + &self, + tile: &Tile, + local_x: usize, + local_z: usize, + y: i32, + _surface_y: i32, + block_name: &str, + ) -> Option<&'static str> { + let idx = local_z * TILE_SIZE + local_x; + let data = tile.get_layer_data(LAYER_RESOURCES)?; + if let LayerBuffer::Nibble(nibbles) = data { + let byte = nibbles[idx / 2]; + let value = if idx.is_multiple_of(2) { + byte >> 4 + } else { + byte & 0xF + }; + if value > 0 && block_name == "minecraft:stone" && y > -60 { + let ore = match value { + 1 => "minecraft:coal_ore", + 2 => "minecraft:iron_ore", + 3 => "minecraft:copper_ore", + 4 => "minecraft:gold_ore", + 5 => "minecraft:redstone_ore", + 6 => "minecraft:lapis_ore", + 7 => "minecraft:diamond_ore", + 8 => "minecraft:emerald_ore", + _ => return None, + }; + Some(ore) + } else { + None + } + } else { + None + } + } +} + +/// Look up a Minecraft biome name from a Terrafier internal biome ID, +/// using the biome-db crate as the source of truth. +fn terrafier_biome_name(id: u8) -> &'static str { + // Terrafier internal biome IDs → actual Minecraft biome IDs + const MC_BIOME_IDS: [i32; 11] = [1, 2, 4, 5, 6, 12, 33, 18, 0, 7, 36]; + + static NAMES: OnceLock<[&'static str; 11]> = OnceLock::new(); + let names = NAMES.get_or_init(|| { + let db = BiomeDb::default(); + let mut arr = [""; 11]; + for (i, &mc_id) in MC_BIOME_IDS.iter().enumerate() { + let full = db + .get_by_id(mc_id) + .map(|e| format!("minecraft:{}", e.name)) + .unwrap_or_else(|| "minecraft:plains".to_string()); + arr[i] = Box::leak(full.into_boxed_str()); + } + arr + }); + + names.get(id as usize).copied().unwrap_or("minecraft:plains") +} + +pub struct BiomeLayerExport; +impl LayerExport for BiomeLayerExport { + fn layer_id(&self) -> u32 { + LAYER_BIOME + } + + fn modify_block( + &self, + _tile: &Tile, + _local_x: usize, + _local_z: usize, + _y: i32, + _surface_y: i32, + _block_name: &str, + ) -> Option<&'static str> { + None + } + + fn biome_name(&self, tile: &Tile, local_x: usize, local_z: usize) -> Option<&'static str> { + let idx = local_z * TILE_SIZE + local_x; + let data = tile.get_layer_data(LAYER_BIOME)?; + if let LayerBuffer::Byte(bytes) = data { + Some(terrafier_biome_name(bytes[idx])) + } else { + None + } + } +} diff --git a/core/src/io/layer_export/caves_frost.rs b/core/src/io/layer_export/caves_frost.rs new file mode 100644 index 0000000..9de490f --- /dev/null +++ b/core/src/io/layer_export/caves_frost.rs @@ -0,0 +1,85 @@ +use crate::coords::TILE_SIZE; +use crate::io::layer_export::LayerExport; +use crate::model::layers::{LAYER_CAVES, LAYER_FROST}; +use crate::model::tile::{LayerBuffer, Tile}; + +pub struct CavesLayerExport; +impl LayerExport for CavesLayerExport { + fn layer_id(&self) -> u32 { + LAYER_CAVES + } + + fn modify_block( + &self, + tile: &Tile, + local_x: usize, + local_z: usize, + y: i32, + surface_y: i32, + block_name: &str, + ) -> Option<&'static str> { + let idx = local_z * TILE_SIZE + local_x; + let data = tile.get_layer_data(LAYER_CAVES)?; + if let LayerBuffer::Nibble(nibbles) = data { + let byte = nibbles[idx / 2]; + let value = if idx.is_multiple_of(2) { + byte >> 4 + } else { + byte & 0xF + }; + if value > 0 && y < surface_y && y >= surface_y - 12 && y > -60 { + match block_name { + "minecraft:stone" + | "minecraft:deepslate" + | "minecraft:dirt" + | "minecraft:grass_block" + | "minecraft:sand" + | "minecraft:sandstone" + | "minecraft:gravel" => Some("minecraft:air"), + _ => None, + } + } else { + None + } + } else { + None + } + } +} + +pub struct FrostLayerExport; +impl LayerExport for FrostLayerExport { + fn layer_id(&self) -> u32 { + LAYER_FROST + } + + fn modify_block( + &self, + tile: &Tile, + local_x: usize, + local_z: usize, + y: i32, + surface_y: i32, + _block_name: &str, + ) -> Option<&'static str> { + let idx = local_z * TILE_SIZE + local_x; + let data = tile.get_layer_data(LAYER_FROST)?; + if let LayerBuffer::Bit(bits) = data { + let word = bits[idx / 64]; + let bit = (word >> (idx % 64)) & 1; + if bit == 1 && y == surface_y { + if _block_name == "minecraft:water" { + Some("minecraft:ice") + } else if _block_name == "minecraft:grass_block" { + Some("minecraft:snow_block") + } else { + None + } + } else { + None + } + } else { + None + } + } +} diff --git a/core/src/io/layer_export/mod.rs b/core/src/io/layer_export/mod.rs new file mode 100644 index 0000000..c4f30e1 --- /dev/null +++ b/core/src/io/layer_export/mod.rs @@ -0,0 +1,66 @@ +//! Layer export system — applies layer data to modify blocks during Minecraft export. + +use crate::model::tile::Tile; + +/// A layer exporter knows how to modify blocks based on layer data. +pub trait LayerExport: Send + Sync { + fn layer_id(&self) -> u32; + fn modify_block( + &self, + tile: &Tile, + local_x: usize, + local_z: usize, + y: i32, + surface_y: i32, + block_name: &str, + ) -> Option<&'static str>; + + /// Optional biome override for this position. + fn biome_name(&self, tile: &Tile, local_x: usize, local_z: usize) -> Option<&'static str> { + let _ = (tile, local_x, local_z); + None + } +} + +/// Apply all layer exporters in order. The first exporter that returns a replacement wins. +pub fn apply_layers<'a>( + tile: &Tile, + local_x: usize, + local_z: usize, + y: i32, + surface_y: i32, + block_name: &'a str, + layer_exporters: &[&dyn LayerExport], +) -> &'a str { + for exporter in layer_exporters { + if let Some(replacement) = + exporter.modify_block(tile, local_x, local_z, y, surface_y, block_name) + { + return replacement; + } + } + block_name +} + +/// Resolve the biome name from layer exporters, falling back to plains. +pub fn biome_name( + tile: &Tile, + local_x: usize, + local_z: usize, + layer_exporters: &[&dyn LayerExport], +) -> &'static str { + for exporter in layer_exporters { + if let Some(name) = exporter.biome_name(tile, local_x, local_z) { + return name; + } + } + "minecraft:plains" +} + +mod caves_frost; +mod river_trees; +mod biome_res; + +pub use caves_frost::{CavesLayerExport, FrostLayerExport}; +pub use river_trees::{RiverLayerExport, TreesLayerExport}; +pub use biome_res::{BiomeLayerExport, ResourcesLayerExport}; diff --git a/core/src/io/layer_export/river_trees.rs b/core/src/io/layer_export/river_trees.rs new file mode 100644 index 0000000..17d0a6c --- /dev/null +++ b/core/src/io/layer_export/river_trees.rs @@ -0,0 +1,86 @@ +use crate::coords::TILE_SIZE; +use crate::io::layer_export::LayerExport; +use crate::model::layers::{LAYER_RIVER, LAYER_TREES}; +use crate::model::tile::{LayerBuffer, Tile}; + +pub struct RiverLayerExport; +impl LayerExport for RiverLayerExport { + fn layer_id(&self) -> u32 { + LAYER_RIVER + } + + fn modify_block( + &self, + tile: &Tile, + local_x: usize, + local_z: usize, + y: i32, + surface_y: i32, + block_name: &str, + ) -> Option<&'static str> { + let idx = local_z * TILE_SIZE + local_x; + let data = tile.get_layer_data(LAYER_RIVER)?; + if let LayerBuffer::Byte(bytes) = data { + let value = bytes[idx]; + if value > 0 { + let depth = value as i32; + if y <= surface_y && y > surface_y - depth { + if y >= surface_y - 1 { + Some("minecraft:water") + } else { + match block_name { + "minecraft:stone" + | "minecraft:deepslate" + | "minecraft:dirt" + | "minecraft:grass_block" + | "minecraft:sand" + | "minecraft:sandstone" + | "minecraft:gravel" => Some("minecraft:air"), + _ => None, + } + } + } else { + None + } + } else { + None + } + } else { + None + } + } +} + +pub struct TreesLayerExport; +impl LayerExport for TreesLayerExport { + fn layer_id(&self) -> u32 { + LAYER_TREES + } + + fn modify_block( + &self, + tile: &Tile, + local_x: usize, + local_z: usize, + y: i32, + surface_y: i32, + block_name: &str, + ) -> Option<&'static str> { + let idx = local_z * TILE_SIZE + local_x; + let data = tile.get_layer_data(LAYER_TREES)?; + if let LayerBuffer::Byte(bytes) = data { + let value = bytes[idx]; + if value > 0 + && block_name == "minecraft:air" + && y > surface_y + && y <= surface_y + value as i32 + { + Some("minecraft:oak_log") + } else { + None + } + } else { + None + } + } +} diff --git a/core/src/io/minecraft.rs b/core/src/io/minecraft.rs deleted file mode 100755 index 008b6de..0000000 --- a/core/src/io/minecraft.rs +++ /dev/null @@ -1,783 +0,0 @@ -//! Minecraft world I/O — read and write Java Edition saves. - -use log; -use std::collections::{BTreeMap, HashMap}; -use std::fs; -use std::path::{Path, PathBuf}; -use thiserror::Error; - -use terrafier_fastanvil::io::region::Region; -use terrafier_nbt::io::reader::read_gzip; - -use crate::io::layer_export::{LayerExport, apply_layers, biome_name}; -use crate::model::dimension::Dimension; -use crate::model::platform::Platform; -use crate::model::tile::Tile; -use crate::model::world::World; - -#[allow(clippy::type_complexity)] -type RegionTiles<'a> = BTreeMap<(i32, i32), Vec<(&'a (i32, i32), &'a Tile)>>; - -#[derive(Error, Debug)] -pub enum MinecraftIOError { - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - #[error("NBT parse error: {0}")] - NbtParse(#[from] terrafier_nbt::io::reader::ReadError), - #[error("NBT write error: {0}")] - NbtWrite(#[from] terrafier_nbt::io::writer::WriteError), - #[error("Region error: {0}")] - Region(#[from] terrafier_fastanvil::io::region::RegionError), - #[error("Not a Minecraft save directory: {0}")] - NotASave(String), - #[error("Missing level.dat")] - MissingLevelDat, - #[error("Missing region directory")] - MissingRegionDir, - #[error("Unsupported data version: {0}")] - UnsupportedVersion(i32), - #[error("Invalid chunk data: {0}")] - InvalidChunk(String), -} - -pub type Result = std::result::Result; - -/// Infer Minecraft version from DataVersion number. -pub fn version_from_data_version(dv: i32) -> Option { - match dv { - 2860..=2865 => Some(Platform { - id: "java_1_18".into(), - display_name: "Minecraft Java 1.18".into(), - min_height: -64, - max_height: 320, - }), - 2866..=2974 => { - log::warn!( - "DataVersion {} is between 1.18 and 1.19, falling back to 1.18", - dv - ); - Some(Platform { - id: "java_1_18".into(), - display_name: "Minecraft Java 1.18".into(), - min_height: -64, - max_height: 320, - }) - } - 2975..=3117 => Some(Platform { - id: "java_1_19".into(), - display_name: "Minecraft Java 1.19".into(), - min_height: -64, - max_height: 320, - }), - 3118..=3336 => { - log::warn!( - "DataVersion {} is between 1.19 and 1.20, falling back to 1.19", - dv - ); - Some(Platform { - id: "java_1_19".into(), - display_name: "Minecraft Java 1.19".into(), - min_height: -64, - max_height: 320, - }) - } - 3337..=3460 => Some(Platform { - id: "java_1_20".into(), - display_name: "Minecraft Java 1.20".into(), - min_height: -64, - max_height: 320, - }), - 3461..=3577 => { - log::warn!( - "DataVersion {} is between 1.20 and 1.20.5, falling back to 1.20", - dv - ); - Some(Platform { - id: "java_1_20".into(), - display_name: "Minecraft Java 1.20 (fallback)".into(), - min_height: -64, - max_height: 320, - }) - } - 3578..=3700 => Some(Platform { - id: "java_1_20_5".into(), - display_name: "Minecraft Java 1.20.5+".into(), - min_height: -64, - max_height: 320, - }), - 3701..=3818 => { - log::warn!( - "DataVersion {} is between 1.20.5 and 1.21, falling back to 1.20.5", - dv - ); - Some(Platform { - id: "java_1_20_5".into(), - display_name: "Minecraft Java 1.20.5 (fallback)".into(), - min_height: -64, - max_height: 320, - }) - } - 3819..=3953 => Some(Platform { - id: "java_1_21".into(), - display_name: "Minecraft Java 1.21".into(), - min_height: -64, - max_height: 320, - }), - 3954..=4100 => Some(Platform { - id: "java_1_21_2".into(), - display_name: "Minecraft Java 1.21.2+".into(), - min_height: -64, - max_height: 320, - }), - _ => None, - } -} - -/// Load a Minecraft save directory into a Terrafier World model. -pub fn load_save(path: &Path) -> Result { - if !path.is_dir() { - return Err(MinecraftIOError::NotASave(path.display().to_string())); - } - - // Read level.dat - let level_dat_path = path.join("level.dat"); - if !level_dat_path.exists() { - return Err(MinecraftIOError::MissingLevelDat); - } - let level_data = fs::read(&level_dat_path)?; - let level_tag = read_gzip(&level_data)?; - - let (world_name, seed, data_version) = parse_level_dat(&level_tag); - let platform = version_from_data_version(data_version) - .ok_or(MinecraftIOError::UnsupportedVersion(data_version))?; - - // Read region files - let region_dir = path.join("region"); - if !region_dir.is_dir() { - return Err(MinecraftIOError::MissingRegionDir); - } - - let mut tiles: HashMap<(i32, i32), Tile> = HashMap::new(); - - let region_entries = fs::read_dir(®ion_dir)?; - for entry in region_entries { - let entry = entry?; - let path = entry.path(); - if path.extension().is_some_and(|e| e == "mca") { - let file_name = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); - let parts: Vec<&str> = file_name.split('.').collect(); - if parts.len() >= 3 - && let (Ok(rx), Ok(rz)) = (parts[1].parse::(), parts[2].parse::()) - { - let region_bytes = fs::read(&path)?; - let region = Region::from_bytes(rx, rz, ®ion_bytes)?; - for (local_x, local_z) in region.chunk_coords() { - let chunk_data = region.get_chunk_data(local_x, local_z).unwrap(); - if let Ok(chunk_tag) = read_gzip(chunk_data) - && let Some(chunk) = - terrafier_fastanvil::io::chunk::Chunk::from_nbt(&chunk_tag) - { - let tile_x = chunk.x >> 3; - let tile_z = chunk.z >> 3; - - // 8 chunks per tile (128 blocks / 16 blocks per chunk) - let chunk_local_x = (chunk.x & 7) as usize; - let chunk_local_z = (chunk.z & 7) as usize; - - let tile = tiles.entry((tile_x, tile_z)).or_insert_with(|| { - Tile::new(tile_x, tile_z, platform.min_height, platform.max_height) - }); - - for lx in 0..16usize { - for lz in 0..16usize { - let mut surface_y = None; - - if !chunk.sections.is_empty() { - let mut sorted: Vec<_> = chunk.sections.iter().collect(); - sorted.sort_by_key(|b| std::cmp::Reverse(b.section_y)); - - for section in &sorted { - if section.palette.is_empty() { - continue; - } - - let has_blocks = section.palette.iter().any(|p| { - p.get("Name").is_some_and(|n| { - matches!( - n, - terrafier_nbt::Tag::String(s) - if s != "minecraft:air" - ) - }) - }); - - if !has_blocks { - continue; - } - - if section.block_data.is_empty() { - if section.palette[0].get("Name").is_some_and(|n| { - matches!( - n, - terrafier_nbt::Tag::String(s) - if s == "minecraft:air" - ) - }) { - continue; - } - surface_y = Some((section.section_y as i32) * 16 + 15); - break; - } - - surface_y = Some((section.section_y as i32) * 16 + 15); - break; - } - } - - let tile_local_x = chunk_local_x * 16 + lx; - let tile_local_z = chunk_local_z * 16 + lz; - - if tile_local_x < 128 - && tile_local_z < 128 - && let Some(y) = surface_y - { - let clamped = - (y as i16).clamp(tile.min_height, tile.max_height); - tile.heightmap[tile_local_z * 128 + tile_local_x] = clamped; - } - } - } - } - } - } - } - } - - let dimension = Dimension { - name: "overworld".to_string(), - tiles, - min_height: platform.min_height, - max_height: platform.max_height, - seed, - }; - - Ok(World { - name: world_name, - platform, - dimensions: vec![dimension], - seed, - }) -} - -/// Save a Terrafier World to a Minecraft save directory. -pub fn save_world(world: &World, output_path: &Path) -> Result<()> { - fs::create_dir_all(output_path.join("region"))?; - - // Write level.dat - let level_tag = build_level_dat(world)?; - let level_bytes = terrafier_nbt::io::writer::to_gzip_bytes(&level_tag)?; - fs::write(output_path.join("level.dat"), &level_bytes)?; - - let dim = match world.dimensions.first() { - Some(d) => d, - None => return Ok(()), - }; - - // Group tiles by region (512×512 blocks = 4×4 tiles of 128×128 each) - let mut regions: RegionTiles = BTreeMap::new(); - for (key, tile) in &dim.tiles { - let (tx, tz) = key; - let rx = tx >> 2; - let rz = tz >> 2; - regions.entry((rx, rz)).or_default().push((key, tile)); - } - - for ((rx, rz), tile_refs) in ®ions { - let mut region = terrafier_fastanvil::io::region::Region::new(*rx, *rz); - - for (_key, tile) in tile_refs { - // Each tile is 128x128 blocks = 8x8 chunks - for chunk_lx in 0..8usize { - for chunk_lz in 0..8usize { - let chunk_x = tile.x * 8 + chunk_lx as i32; - let chunk_z = tile.z * 8 + chunk_lz as i32; - - let region_local_x = (chunk_x & 31) as u8; - let region_local_z = (chunk_z & 31) as u8; - - let chunk_data = build_chunk_nbt(chunk_x, chunk_z, tile, chunk_lx, chunk_lz)?; - - // Skip completely empty chunks (no blocks) - if chunk_data.is_empty() { - continue; - } - - region.set_chunk_data(region_local_x, region_local_z, chunk_data); - } - } - } - - let region_bytes = region.to_bytes()?; - let file_name = format!("r.{}.{}.mca", rx, rz); - fs::write(output_path.join("region").join(&file_name), ®ion_bytes)?; - } - - Ok(()) -} - -// ---- Chunk building helpers ---- - -/// Minimum number of bits needed to represent values up to n-1 (clamped to MC minimum 4). -fn bits_needed(n: usize) -> usize { - if n <= 1 { - return 4; - } - let bits = (usize::BITS - (n - 1).leading_zeros()) as usize; - bits.max(4) -} - -/// Pack palette indices into a compact long array (Minecraft block state format). -fn pack_indices(indices: &[u16], bits: usize) -> Vec { - if indices.is_empty() || bits == 0 { - return Vec::new(); - } - let total_bits = indices.len() * bits; - let longs = total_bits.div_ceil(64); - let mut data = vec![0i64; longs]; - let mask = (1i64 << bits) - 1; - for (i, &idx) in indices.iter().enumerate() { - let bit_pos = i * bits; - let long_idx = bit_pos / 64; - let bit_offset = bit_pos % 64; - data[long_idx] |= (idx as i64 & mask) << bit_offset; - } - data -} - -/// Determine the Minecraft block at a given (global_y) column position. -fn block_name(terrain: u8, y: i32, surface_y: i32) -> &'static str { - // Water terrain: everything up to the surface is water - if terrain == 6 { - if y <= surface_y { - return "minecraft:water"; - } else { - return "minecraft:air"; - } - } - - // Non-water terrain - if y == surface_y { - match terrain { - 1 | 4 => "minecraft:sand", - 3 => "minecraft:stone", - _ => "minecraft:grass_block", - } - } else if y > surface_y { - "minecraft:air" - } else if y > surface_y - 4 { - match terrain { - 1 | 4 => "minecraft:sand", - 3 => "minecraft:stone", - _ => "minecraft:dirt", - } - } else if y < -60 { - "minecraft:bedrock" - } else if y < 0 { - "minecraft:deepslate" - } else { - "minecraft:stone" - } -} - -/// Build a single chunk's NBT data (raw uncompressed bytes). -/// Returns empty Vec if the chunk has no blocks at all. -fn build_chunk_nbt( - chunk_x: i32, - chunk_z: i32, - tile: &Tile, - chunk_lx: usize, - chunk_lz: usize, -) -> Result> { - let mut compound = HashMap::new(); - compound.insert("xPos".into(), terrafier_nbt::Tag::Int(chunk_x)); - compound.insert("zPos".into(), terrafier_nbt::Tag::Int(chunk_z)); - compound.insert("DataVersion".into(), terrafier_nbt::Tag::Int(3954)); - compound.insert("Status".into(), terrafier_nbt::Tag::String("full".into())); - - // Palette of all blocks used in this chunk - const BLOCK_SET: &[&str] = &[ - "minecraft:air", - "minecraft:grass_block", - "minecraft:dirt", - "minecraft:stone", - "minecraft:bedrock", - "minecraft:sand", - "minecraft:sandstone", - "minecraft:water", - "minecraft:deepslate", - ]; - - let min_sec = (tile.min_height as i32 >> 4).max(-4); - let max_sec = (tile.max_height as i32 >> 4).min(20); - - let mut sections: Vec = Vec::new(); - - for sec_y in min_sec..=max_sec { - let sec_base = sec_y * 16; - - // Collect block indices for all 4096 positions in this section - // MC order: y * 16 * 16 + z * 16 + x = y * 256 + z * 16 + x - let mut indices = Vec::with_capacity(4096); - for y_rel in 0..16 { - let global_y = sec_base + y_rel; - for lz in 0..16 { - for lx in 0..16 { - let tile_lx = chunk_lx * 16 + lx; - let tile_lz = chunk_lz * 16 + lz; - - let idx = if tile_lx >= 128 || tile_lz >= 128 { - 0u16 // air for out-of-bounds - } else { - let surface_y = tile.heightmap[tile_lz * 128 + tile_lx] as i32; - let terrain_id = tile.terrain[tile_lz * 128 + tile_lx]; - let name = block_name(terrain_id, global_y, surface_y); - BLOCK_SET.iter().position(|s| *s == name).unwrap_or(0) as u16 - }; - indices.push(idx); - } - } - } - - // Skip sections with only air blocks - if !indices.iter().any(|&i| i != 0) { - continue; - } - - let bits = bits_needed(BLOCK_SET.len()); - let packed = pack_indices(&indices, bits); - - let palette_tags: Vec = BLOCK_SET - .iter() - .map(|name| { - let mut entry = HashMap::new(); - entry.insert("Name".into(), terrafier_nbt::Tag::String(name.to_string())); - terrafier_nbt::Tag::Compound(entry) - }) - .collect(); - - let mut block_states = HashMap::new(); - block_states.insert("palette".into(), terrafier_nbt::Tag::List(palette_tags)); - block_states.insert("data".into(), terrafier_nbt::Tag::LongArray(packed)); - - let biome_palette = vec![{ - let mut b = HashMap::new(); - b.insert( - "Name".into(), - terrafier_nbt::Tag::String("minecraft:plains".into()), - ); - terrafier_nbt::Tag::Compound(b) - }]; - let mut biomes = HashMap::new(); - biomes.insert("palette".into(), terrafier_nbt::Tag::List(biome_palette)); - - let mut sec_compound = HashMap::new(); - sec_compound.insert("Y".into(), terrafier_nbt::Tag::Byte(sec_y as i8)); - sec_compound.insert( - "block_states".into(), - terrafier_nbt::Tag::Compound(block_states), - ); - sec_compound.insert("biomes".into(), terrafier_nbt::Tag::Compound(biomes)); - - sections.push(terrafier_nbt::Tag::Compound(sec_compound)); - } - - // If no sections have any blocks, return empty - if sections.is_empty() { - return Ok(Vec::new()); - } - - compound.insert("sections".into(), terrafier_nbt::Tag::List(sections)); - - let chunk_tag = terrafier_nbt::Tag::Compound(compound); - let bytes = terrafier_nbt::io::writer::to_bytes(&chunk_tag)?; - Ok(bytes) -} - -/// Build a single chunk's NBT data, applying layer exporters for block/biome overrides. -fn build_chunk_nbt_with_layers( - chunk_x: i32, - chunk_z: i32, - tile: &Tile, - chunk_lx: usize, - chunk_lz: usize, - layer_exporters: &[&dyn LayerExport], -) -> Result> { - let mut compound = HashMap::new(); - compound.insert("xPos".into(), terrafier_nbt::Tag::Int(chunk_x)); - compound.insert("zPos".into(), terrafier_nbt::Tag::Int(chunk_z)); - compound.insert("DataVersion".into(), terrafier_nbt::Tag::Int(3954)); - compound.insert("Status".into(), terrafier_nbt::Tag::String("full".into())); - - const BLOCK_SET: &[&str] = &[ - "minecraft:air", - "minecraft:grass_block", - "minecraft:dirt", - "minecraft:stone", - "minecraft:bedrock", - "minecraft:sand", - "minecraft:sandstone", - "minecraft:water", - "minecraft:deepslate", - "minecraft:snow_block", - "minecraft:ice", - "minecraft:oak_log", - "minecraft:coal_ore", - "minecraft:iron_ore", - "minecraft:copper_ore", - "minecraft:gold_ore", - "minecraft:redstone_ore", - "minecraft:lapis_ore", - "minecraft:diamond_ore", - "minecraft:emerald_ore", - ]; - - let min_sec = (tile.min_height as i32 >> 4).max(-4); - let max_sec = (tile.max_height as i32 >> 4).min(20); - - let mut sections: Vec = Vec::new(); - - for sec_y in min_sec..=max_sec { - let sec_base = sec_y * 16; - - let mut indices = Vec::with_capacity(4096); - for y_rel in 0..16 { - let global_y = sec_base + y_rel; - for lz in 0..16 { - for lx in 0..16 { - let tile_lx = chunk_lx * 16 + lx; - let tile_lz = chunk_lz * 16 + lz; - - let idx = if tile_lx >= 128 || tile_lz >= 128 { - 0u16 - } else { - let surface_y = tile.heightmap[tile_lz * 128 + tile_lx] as i32; - let terrain_id = tile.terrain[tile_lz * 128 + tile_lx]; - let base_name = block_name(terrain_id, global_y, surface_y); - let name = apply_layers( - tile, - tile_lx, - tile_lz, - global_y, - surface_y, - base_name, - layer_exporters, - ); - BLOCK_SET.iter().position(|s| *s == name).unwrap_or(0) as u16 - }; - indices.push(idx); - } - } - } - - if !indices.iter().any(|&i| i != 0) { - continue; - } - - let used_blocks: Vec<&str> = { - let mut seen = std::collections::BTreeSet::new(); - let mut names = Vec::new(); - for &i in &indices { - if i < BLOCK_SET.len() as u16 && seen.insert(i) { - names.push(BLOCK_SET[i as usize]); - } - } - names - }; - - let bits = bits_needed(used_blocks.len()); - let mut palette_map: std::collections::HashMap<&str, u16> = HashMap::new(); - for (pi, name) in used_blocks.iter().enumerate() { - palette_map.insert(name, pi as u16); - } - let remapped: Vec = indices - .iter() - .map(|&i| { - if i < BLOCK_SET.len() as u16 { - palette_map[BLOCK_SET[i as usize]] - } else { - 0 - } - }) - .collect(); - let packed = pack_indices(&remapped, bits); - - let palette_tags: Vec = used_blocks - .iter() - .map(|name| { - let mut entry = HashMap::new(); - entry.insert("Name".into(), terrafier_nbt::Tag::String(name.to_string())); - terrafier_nbt::Tag::Compound(entry) - }) - .collect(); - - let mut block_states = HashMap::new(); - block_states.insert("palette".into(), terrafier_nbt::Tag::List(palette_tags)); - block_states.insert("data".into(), terrafier_nbt::Tag::LongArray(packed)); - - let biome_at = biome_name(tile, chunk_lx * 16, chunk_lz * 16, layer_exporters); - let biome_palette = vec![{ - let mut b = HashMap::new(); - b.insert("Name".into(), terrafier_nbt::Tag::String(biome_at.into())); - terrafier_nbt::Tag::Compound(b) - }]; - let mut biomes = HashMap::new(); - biomes.insert("palette".into(), terrafier_nbt::Tag::List(biome_palette)); - - let mut sec_compound = HashMap::new(); - sec_compound.insert("Y".into(), terrafier_nbt::Tag::Byte(sec_y as i8)); - sec_compound.insert( - "block_states".into(), - terrafier_nbt::Tag::Compound(block_states), - ); - sec_compound.insert("biomes".into(), terrafier_nbt::Tag::Compound(biomes)); - - sections.push(terrafier_nbt::Tag::Compound(sec_compound)); - } - - if sections.is_empty() { - return Ok(Vec::new()); - } - - compound.insert("sections".into(), terrafier_nbt::Tag::List(sections)); - - let chunk_tag = terrafier_nbt::Tag::Compound(compound); - let bytes = terrafier_nbt::io::writer::to_bytes(&chunk_tag)?; - Ok(bytes) -} - -/// Save a Terrafier World to a Minecraft save directory, applying layer exporters. -pub fn save_world_with_layers( - world: &World, - output_path: &Path, - layer_exporters: &[&dyn LayerExport], -) -> Result<()> { - fs::create_dir_all(output_path.join("region"))?; - - let level_tag = build_level_dat(world)?; - let level_bytes = terrafier_nbt::io::writer::to_gzip_bytes(&level_tag)?; - fs::write(output_path.join("level.dat"), &level_bytes)?; - - let dim = match world.dimensions.first() { - Some(d) => d, - None => return Ok(()), - }; - - let mut regions: RegionTiles = BTreeMap::new(); - for (key, tile) in &dim.tiles { - let (tx, tz) = key; - let rx = tx >> 2; - let rz = tz >> 2; - regions.entry((rx, rz)).or_default().push((key, tile)); - } - - for ((rx, rz), tile_refs) in ®ions { - let mut region = terrafier_fastanvil::io::region::Region::new(*rx, *rz); - - for (_key, tile) in tile_refs { - for chunk_lx in 0..8usize { - for chunk_lz in 0..8usize { - let chunk_x = tile.x * 8 + chunk_lx as i32; - let chunk_z = tile.z * 8 + chunk_lz as i32; - - let region_local_x = (chunk_x & 31) as u8; - let region_local_z = (chunk_z & 31) as u8; - - let chunk_data = build_chunk_nbt_with_layers( - chunk_x, - chunk_z, - tile, - chunk_lx, - chunk_lz, - layer_exporters, - )?; - - if chunk_data.is_empty() { - continue; - } - - region.set_chunk_data(region_local_x, region_local_z, chunk_data); - } - } - } - - let region_bytes = region.to_bytes()?; - let file_name = format!("r.{}.{}.mca", rx, rz); - fs::write(output_path.join("region").join(&file_name), ®ion_bytes)?; - } - - Ok(()) -} - -fn parse_level_dat(root: &terrafier_nbt::Tag) -> (String, u64, i32) { - let default = || ("Unknown".to_string(), 0u64, 3954i32); - match root { - terrafier_nbt::Tag::Compound(map) => { - let data = match map.get("Data") { - Some(terrafier_nbt::Tag::Compound(d)) => d, - _ => return default(), - }; - let name = match data.get("LevelName") { - Some(terrafier_nbt::Tag::String(s)) => s.clone(), - _ => "Unknown".to_string(), - }; - let seed = match data.get("WorldGenSettings").and_then(|w| match w { - terrafier_nbt::Tag::Compound(m) => m.get("seed"), - _ => None, - }) { - Some(terrafier_nbt::Tag::Long(s)) => *s as u64, - _ => 0, - }; - let data_version = match data.get("DataVersion") { - Some(terrafier_nbt::Tag::Int(v)) => *v, - _ => 3954, - }; - (name, seed, data_version) - } - _ => default(), - } -} - -fn build_level_dat(world: &World) -> Result { - use terrafier_nbt::Tag; - let mut data = HashMap::new(); - data.insert("LevelName".into(), Tag::String(world.name.clone())); - data.insert("DataVersion".into(), Tag::Int(3954)); - data.insert("version".into(), Tag::Int(19133)); - - let mut world_gen = HashMap::new(); - world_gen.insert("seed".into(), Tag::Long(world.seed as i64)); - data.insert("WorldGenSettings".into(), Tag::Compound(world_gen)); - - let mut root = HashMap::new(); - root.insert("Data".into(), Tag::Compound(data)); - - Ok(Tag::Compound(root)) -} - -pub fn discover_region_files(world_path: &Path) -> Result> { - let region_dir = world_path.join("region"); - if !region_dir.is_dir() { - return Err(MinecraftIOError::MissingRegionDir); - } - - let mut files = Vec::new(); - for entry in fs::read_dir(®ion_dir)? { - let entry = entry?; - let path = entry.path(); - if path.extension().is_some_and(|e| e == "mca") { - files.push(path); - } - } - files.sort(); - Ok(files) -} diff --git a/core/src/io/minecraft/export/blocks.rs b/core/src/io/minecraft/export/blocks.rs new file mode 100644 index 0000000..0e2a504 --- /dev/null +++ b/core/src/io/minecraft/export/blocks.rs @@ -0,0 +1,95 @@ +//! Block name mapping — terrain type → Minecraft block IDs. + +pub(crate) const BLOCK_SET: &[&str] = &[ + "minecraft:air", + "minecraft:grass_block", + "minecraft:dirt", + "minecraft:stone", + "minecraft:bedrock", + "minecraft:sand", + "minecraft:sandstone", + "minecraft:water", + "minecraft:deepslate", +]; + +pub(crate) const BLOCK_SET_EXTENDED: &[&str] = &[ + "minecraft:air", + "minecraft:grass_block", + "minecraft:dirt", + "minecraft:stone", + "minecraft:bedrock", + "minecraft:sand", + "minecraft:sandstone", + "minecraft:water", + "minecraft:deepslate", + "minecraft:snow_block", + "minecraft:ice", + "minecraft:oak_log", + "minecraft:coal_ore", + "minecraft:iron_ore", + "minecraft:copper_ore", + "minecraft:gold_ore", + "minecraft:redstone_ore", + "minecraft:lapis_ore", + "minecraft:diamond_ore", + "minecraft:emerald_ore", +]; + +/// Minimum number of bits needed to represent values up to n-1 (clamped to MC minimum 4). +pub(crate) fn bits_needed(n: usize) -> usize { + if n <= 1 { + return 4; + } + let bits = (usize::BITS - (n - 1).leading_zeros()) as usize; + bits.max(4) +} + +/// Pack palette indices into a compact long array (Minecraft block state format). +pub(crate) fn pack_indices(indices: &[u16], bits: usize) -> Vec { + if indices.is_empty() || bits == 0 { + return Vec::new(); + } + let total_bits = indices.len() * bits; + let longs = total_bits.div_ceil(64); + let mut data = vec![0i64; longs]; + let mask = (1i64 << bits) - 1; + for (i, &idx) in indices.iter().enumerate() { + let bit_pos = i * bits; + let long_idx = bit_pos / 64; + let bit_offset = bit_pos % 64; + data[long_idx] |= (idx as i64 & mask) << bit_offset; + } + data +} + +pub(crate) fn block_name(terrain: u8, y: i32, surface_y: i32) -> &'static str { + if terrain == 6 { + if y <= surface_y { + return "minecraft:water"; + } else { + return "minecraft:air"; + } + } + + if y == surface_y { + match terrain { + 1 | 4 => "minecraft:sand", + 3 => "minecraft:stone", + _ => "minecraft:grass_block", + } + } else if y > surface_y { + "minecraft:air" + } else if y > surface_y - 4 { + match terrain { + 1 | 4 => "minecraft:sand", + 3 => "minecraft:stone", + _ => "minecraft:dirt", + } + } else if y < -60 { + "minecraft:bedrock" + } else if y < 0 { + "minecraft:deepslate" + } else { + "minecraft:stone" + } +} diff --git a/core/src/io/minecraft/export/chunk.rs b/core/src/io/minecraft/export/chunk.rs new file mode 100644 index 0000000..81ba1f3 --- /dev/null +++ b/core/src/io/minecraft/export/chunk.rs @@ -0,0 +1,191 @@ +use std::collections::{BTreeSet, HashMap}; +use crate::io::layer_export::{apply_layers, biome_name, LayerExport}; +use crate::io::minecraft::{data_version, Result}; +use crate::model::tile::Tile; +use super::blocks::{bits_needed, pack_indices, BLOCK_SET, BLOCK_SET_EXTENDED}; +use super::block_name; + +fn make_section( + sec_y: i32, + block_states: HashMap, + biomes: HashMap, +) -> terrafier_nbt::Tag { + let mut sec = HashMap::new(); + sec.insert("Y".into(), terrafier_nbt::Tag::Byte(sec_y as i8)); + sec.insert("block_states".into(), terrafier_nbt::Tag::Compound(block_states)); + sec.insert("biomes".into(), terrafier_nbt::Tag::Compound(biomes)); + terrafier_nbt::Tag::Compound(sec) +} + +fn make_biomes(biome_name: &str) -> HashMap { + let mut entry = HashMap::new(); + entry.insert("Name".into(), terrafier_nbt::Tag::String(biome_name.into())); + let palette = vec![terrafier_nbt::Tag::Compound(entry)]; + let mut biomes = HashMap::new(); + biomes.insert("palette".into(), terrafier_nbt::Tag::List(palette)); + biomes +} + +/// Build a single chunk's NBT data (raw uncompressed bytes). +/// Returns empty Vec if the chunk has no blocks at all. +pub(super) fn build_chunk_nbt( + chunk_x: i32, + chunk_z: i32, + tile: &Tile, + chunk_lx: usize, + chunk_lz: usize, +) -> Result> { + let mut compound = HashMap::new(); + compound.insert("xPos".into(), terrafier_nbt::Tag::Int(chunk_x)); + compound.insert("zPos".into(), terrafier_nbt::Tag::Int(chunk_z)); + compound.insert("DataVersion".into(), terrafier_nbt::Tag::Int(data_version())); + compound.insert("Status".into(), terrafier_nbt::Tag::String("full".into())); + let min_sec = (tile.min_height as i32 >> 4).max(-4); + let max_sec = (tile.max_height as i32 >> 4).min(20); + let mut sections: Vec = Vec::new(); + for sec_y in min_sec..=max_sec { + let sec_base = sec_y * 16; + let mut indices = Vec::with_capacity(4096); + for y_rel in 0..16 { + let global_y = sec_base + y_rel; + for lz in 0..16 { + for lx in 0..16 { + let tile_lx = chunk_lx * 16 + lx; + let tile_lz = chunk_lz * 16 + lz; + let idx = if tile_lx >= 128 || tile_lz >= 128 { + 0u16 + } else { + let surface_y = tile.heightmap[tile_lz * 128 + tile_lx] as i32; + let terrain_id = tile.terrain[tile_lz * 128 + tile_lx]; + let name = block_name(terrain_id, global_y, surface_y); + BLOCK_SET.iter().position(|s| *s == name).unwrap_or(0) as u16 + }; + indices.push(idx); + } + } + } + if !indices.iter().any(|&i| i != 0) { + continue; + } + let bits = bits_needed(BLOCK_SET.len()); + let packed = pack_indices(&indices, bits); + let palette_tags: Vec = BLOCK_SET + .iter() + .map(|name| { + let mut entry = HashMap::new(); + entry.insert("Name".into(), terrafier_nbt::Tag::String(name.to_string())); + terrafier_nbt::Tag::Compound(entry) + }) + .collect(); + let mut block_states = HashMap::new(); + block_states.insert("palette".into(), terrafier_nbt::Tag::List(palette_tags)); + block_states.insert("data".into(), terrafier_nbt::Tag::LongArray(packed)); + let biomes = make_biomes("minecraft:plains"); + sections.push(make_section(sec_y, block_states, biomes)); + } + if sections.is_empty() { + return Ok(Vec::new()); + } + compound.insert("sections".into(), terrafier_nbt::Tag::List(sections)); + let chunk_tag = terrafier_nbt::Tag::Compound(compound); + let bytes = terrafier_nbt::io::writer::to_bytes(&chunk_tag)?; + Ok(bytes) +} + +/// Build a single chunk's NBT data, applying layer exporters for block/biome overrides. +pub(super) fn build_chunk_nbt_with_layers( + chunk_x: i32, + chunk_z: i32, + tile: &Tile, + chunk_lx: usize, + chunk_lz: usize, + layer_exporters: &[&dyn LayerExport], +) -> Result> { + let mut compound = HashMap::new(); + compound.insert("xPos".into(), terrafier_nbt::Tag::Int(chunk_x)); + compound.insert("zPos".into(), terrafier_nbt::Tag::Int(chunk_z)); + compound.insert("DataVersion".into(), terrafier_nbt::Tag::Int(data_version())); + compound.insert("Status".into(), terrafier_nbt::Tag::String("full".into())); + let min_sec = (tile.min_height as i32 >> 4).max(-4); + let max_sec = (tile.max_height as i32 >> 4).min(20); + let mut sections: Vec = Vec::new(); + for sec_y in min_sec..=max_sec { + let sec_base = sec_y * 16; + let mut indices = Vec::with_capacity(4096); + for y_rel in 0..16 { + let global_y = sec_base + y_rel; + for lz in 0..16 { + for lx in 0..16 { + let tile_lx = chunk_lx * 16 + lx; + let tile_lz = chunk_lz * 16 + lz; + let idx = if tile_lx >= 128 || tile_lz >= 128 { + 0u16 + } else { + let surface_y = tile.heightmap[tile_lz * 128 + tile_lx] as i32; + let terrain_id = tile.terrain[tile_lz * 128 + tile_lx]; + let base_name = block_name(terrain_id, global_y, surface_y); + let name = apply_layers( + tile, tile_lx, tile_lz, global_y, surface_y, base_name, + layer_exporters, + ); + BLOCK_SET_EXTENDED + .iter() + .position(|s| *s == name) + .unwrap_or(0) as u16 + }; + indices.push(idx); + } + } + } + if !indices.iter().any(|&i| i != 0) { + continue; + } + let used_blocks: Vec<&str> = { + let mut seen = BTreeSet::new(); + let mut names = Vec::new(); + for &i in &indices { + if i < BLOCK_SET_EXTENDED.len() as u16 && seen.insert(i) { + names.push(BLOCK_SET_EXTENDED[i as usize]); + } + } + names + }; + let bits = bits_needed(used_blocks.len()); + let mut palette_map: HashMap<&str, u16> = HashMap::new(); + for (pi, name) in used_blocks.iter().enumerate() { + palette_map.insert(name, pi as u16); + } + let remapped: Vec = indices + .iter() + .map(|&i| { + if i < BLOCK_SET_EXTENDED.len() as u16 { + palette_map[BLOCK_SET_EXTENDED[i as usize]] + } else { + 0 + } + }) + .collect(); + let packed = pack_indices(&remapped, bits); + let palette_tags: Vec = used_blocks + .iter() + .map(|name| { + let mut entry = HashMap::new(); + entry.insert("Name".into(), terrafier_nbt::Tag::String(name.to_string())); + terrafier_nbt::Tag::Compound(entry) + }) + .collect(); + let mut block_states = HashMap::new(); + block_states.insert("palette".into(), terrafier_nbt::Tag::List(palette_tags)); + block_states.insert("data".into(), terrafier_nbt::Tag::LongArray(packed)); + let biome_at = biome_name(tile, chunk_lx * 16, chunk_lz * 16, layer_exporters); + let biomes = make_biomes(biome_at); + sections.push(make_section(sec_y, block_states, biomes)); + } + if sections.is_empty() { + return Ok(Vec::new()); + } + compound.insert("sections".into(), terrafier_nbt::Tag::List(sections)); + let chunk_tag = terrafier_nbt::Tag::Compound(compound); + let bytes = terrafier_nbt::io::writer::to_bytes(&chunk_tag)?; + Ok(bytes) +} diff --git a/core/src/io/minecraft/export/mod.rs b/core/src/io/minecraft/export/mod.rs new file mode 100644 index 0000000..6ff4d0c --- /dev/null +++ b/core/src/io/minecraft/export/mod.rs @@ -0,0 +1,7 @@ +mod blocks; +mod chunk; +mod writer; + +pub use writer::save_world; +pub use writer::save_world_with_layers; +pub(crate) use blocks::block_name; diff --git a/core/src/io/minecraft/export/writer.rs b/core/src/io/minecraft/export/writer.rs new file mode 100644 index 0000000..b4f4854 --- /dev/null +++ b/core/src/io/minecraft/export/writer.rs @@ -0,0 +1,148 @@ +//! Minecraft save writing — orchestrate world-to-region export. + +use std::collections::{BTreeMap, HashMap}; +use std::fs; +use std::path::Path; + +use crate::io::layer_export::LayerExport; +use crate::io::minecraft::{data_version, Result}; +use crate::model::tile::Tile; +use crate::model::world::World; + +use super::chunk::{build_chunk_nbt, build_chunk_nbt_with_layers}; + +#[allow(clippy::type_complexity)] +type RegionTiles<'a> = BTreeMap<(i32, i32), Vec<(&'a (i32, i32), &'a Tile)>>; + +/// Save a Terrafier World to a Minecraft save directory. +pub fn save_world(world: &World, output_path: &Path) -> Result<()> { + fs::create_dir_all(output_path.join("region"))?; + + let level_tag = build_level_dat(world)?; + let level_bytes = terrafier_nbt::io::writer::to_gzip_bytes(&level_tag)?; + fs::write(output_path.join("level.dat"), &level_bytes)?; + + let dim = match world.dimensions.first() { + Some(d) => d, + None => return Ok(()), + }; + + let mut regions: RegionTiles = BTreeMap::new(); + for (key, tile) in &dim.tiles { + let (tx, tz) = key; + let rx = tx >> 2; + let rz = tz >> 2; + regions.entry((rx, rz)).or_default().push((key, tile)); + } + + for ((rx, rz), tile_refs) in ®ions { + let mut region = terrafier_fastanvil::io::region::Region::new(*rx, *rz); + + for (_key, tile) in tile_refs { + for chunk_lx in 0..8usize { + for chunk_lz in 0..8usize { + let chunk_x = tile.x * 8 + chunk_lx as i32; + let chunk_z = tile.z * 8 + chunk_lz as i32; + + let region_local_x = (chunk_x & 31) as u8; + let region_local_z = (chunk_z & 31) as u8; + + let chunk_data = + build_chunk_nbt(chunk_x, chunk_z, tile, chunk_lx, chunk_lz)?; + + if chunk_data.is_empty() { + continue; + } + + region.set_chunk_data(region_local_x, region_local_z, chunk_data); + } + } + } + + let region_bytes = region.to_bytes()?; + let file_name = format!("r.{}.{}.mca", rx, rz); + fs::write(output_path.join("region").join(&file_name), ®ion_bytes)?; + } + + Ok(()) +} + +/// Save a Terrafier World to a Minecraft save directory, applying layer exporters. +pub fn save_world_with_layers( + world: &World, + output_path: &Path, + layer_exporters: &[&dyn LayerExport], +) -> Result<()> { + fs::create_dir_all(output_path.join("region"))?; + + let level_tag = build_level_dat(world)?; + let level_bytes = terrafier_nbt::io::writer::to_gzip_bytes(&level_tag)?; + fs::write(output_path.join("level.dat"), &level_bytes)?; + + let dim = match world.dimensions.first() { + Some(d) => d, + None => return Ok(()), + }; + + let mut regions: RegionTiles = BTreeMap::new(); + for (key, tile) in &dim.tiles { + let (tx, tz) = key; + let rx = tx >> 2; + let rz = tz >> 2; + regions.entry((rx, rz)).or_default().push((key, tile)); + } + + for ((rx, rz), tile_refs) in ®ions { + let mut region = terrafier_fastanvil::io::region::Region::new(*rx, *rz); + + for (_key, tile) in tile_refs { + for chunk_lx in 0..8usize { + for chunk_lz in 0..8usize { + let chunk_x = tile.x * 8 + chunk_lx as i32; + let chunk_z = tile.z * 8 + chunk_lz as i32; + + let region_local_x = (chunk_x & 31) as u8; + let region_local_z = (chunk_z & 31) as u8; + + let chunk_data = build_chunk_nbt_with_layers( + chunk_x, + chunk_z, + tile, + chunk_lx, + chunk_lz, + layer_exporters, + )?; + + if chunk_data.is_empty() { + continue; + } + + region.set_chunk_data(region_local_x, region_local_z, chunk_data); + } + } + } + + let region_bytes = region.to_bytes()?; + let file_name = format!("r.{}.{}.mca", rx, rz); + fs::write(output_path.join("region").join(&file_name), ®ion_bytes)?; + } + + Ok(()) +} + +fn build_level_dat(world: &World) -> Result { + use terrafier_nbt::Tag; + let mut data = HashMap::new(); + data.insert("LevelName".into(), Tag::String(world.name.clone())); + data.insert("DataVersion".into(), Tag::Int(data_version())); + data.insert("version".into(), Tag::Int(19133)); + + let mut world_gen = HashMap::new(); + world_gen.insert("seed".into(), Tag::Long(world.seed as i64)); + data.insert("WorldGenSettings".into(), Tag::Compound(world_gen)); + + let mut root = HashMap::new(); + root.insert("Data".into(), Tag::Compound(data)); + + Ok(Tag::Compound(root)) +} diff --git a/core/src/io/minecraft/mod.rs b/core/src/io/minecraft/mod.rs new file mode 100644 index 0000000..e6e6135 --- /dev/null +++ b/core/src/io/minecraft/mod.rs @@ -0,0 +1,36 @@ +mod export; +mod reader; +mod version; + +pub use reader::load_save; +pub use reader::discover_region_files; +pub use export::save_world; +pub use export::save_world_with_layers; +pub use version::version_from_data_version; +pub(crate) use version::data_version; + +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum MinecraftIOError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("NBT parse error: {0}")] + NbtParse(#[from] terrafier_nbt::io::reader::ReadError), + #[error("NBT write error: {0}")] + NbtWrite(#[from] terrafier_nbt::io::writer::WriteError), + #[error("Region error: {0}")] + Region(#[from] terrafier_fastanvil::io::region::RegionError), + #[error("Not a Minecraft save directory: {0}")] + NotASave(String), + #[error("Missing level.dat")] + MissingLevelDat, + #[error("Missing region directory")] + MissingRegionDir, + #[error("Unsupported data version: {0}")] + UnsupportedVersion(i32), + #[error("Invalid chunk data: {0}")] + InvalidChunk(String), +} + +pub type Result = std::result::Result; diff --git a/core/src/io/minecraft/reader.rs b/core/src/io/minecraft/reader.rs new file mode 100644 index 0000000..be570ed --- /dev/null +++ b/core/src/io/minecraft/reader.rs @@ -0,0 +1,195 @@ +//! Minecraft save reading — load chunks from .mca, parse NBT, build tiles. + +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use terrafier_fastanvil::io::region::Region; +use terrafier_nbt::io::reader::read_gzip; + +use crate::model::dimension::Dimension; +use crate::model::tile::Tile; +use crate::model::world::World; + +use super::version::version_from_data_version; +use super::{MinecraftIOError, Result}; + +/// Load a Minecraft save directory into a Terrafier World model. +pub fn load_save(path: &Path) -> Result { + if !path.is_dir() { + return Err(MinecraftIOError::NotASave(path.display().to_string())); + } + + let level_dat_path = path.join("level.dat"); + if !level_dat_path.exists() { + return Err(MinecraftIOError::MissingLevelDat); + } + let level_data = fs::read(&level_dat_path)?; + let level_tag = read_gzip(&level_data)?; + + let (world_name, seed, data_version) = parse_level_dat(&level_tag); + let platform = version_from_data_version(data_version) + .ok_or(MinecraftIOError::UnsupportedVersion(data_version))?; + + let region_dir = path.join("region"); + if !region_dir.is_dir() { + return Err(MinecraftIOError::MissingRegionDir); + } + + let mut tiles: HashMap<(i32, i32), Tile> = HashMap::new(); + + let region_entries = fs::read_dir(®ion_dir)?; + for entry in region_entries { + let entry = entry?; + let path = entry.path(); + if path.extension().is_some_and(|e| e == "mca") { + let file_name = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + let parts: Vec<&str> = file_name.split('.').collect(); + if parts.len() >= 3 + && let (Ok(rx), Ok(rz)) = (parts[1].parse::(), parts[2].parse::()) + { + let region_bytes = fs::read(&path)?; + let region = Region::from_bytes(rx, rz, ®ion_bytes)?; + for (local_x, local_z) in region.chunk_coords() { + let chunk_data = region.get_chunk_data(local_x, local_z).unwrap(); + if let Ok(chunk_tag) = read_gzip(chunk_data) + && let Some(chunk) = + terrafier_fastanvil::io::chunk::Chunk::from_nbt(&chunk_tag) + { + let tile_x = chunk.x >> 3; + let tile_z = chunk.z >> 3; + + let chunk_local_x = (chunk.x & 7) as usize; + let chunk_local_z = (chunk.z & 7) as usize; + + let tile = tiles.entry((tile_x, tile_z)).or_insert_with(|| { + Tile::new(tile_x, tile_z, platform.min_height, platform.max_height) + }); + + for lx in 0..16usize { + for lz in 0..16usize { + let mut surface_y = None; + + if !chunk.sections.is_empty() { + let mut sorted: Vec<_> = chunk.sections.iter().collect(); + sorted.sort_by_key(|b| std::cmp::Reverse(b.section_y)); + + for section in &sorted { + if section.palette.is_empty() { + continue; + } + + let has_blocks = section.palette.iter().any(|p| { + p.get("Name").is_some_and(|n| { + matches!( + n, + terrafier_nbt::Tag::String(s) + if s != "minecraft:air" + ) + }) + }); + + if !has_blocks { + continue; + } + + if section.block_data.is_empty() { + if section.palette[0].get("Name").is_some_and(|n| { + matches!( + n, + terrafier_nbt::Tag::String(s) + if s == "minecraft:air" + ) + }) { + continue; + } + surface_y = Some((section.section_y as i32) * 16 + 15); + break; + } + + surface_y = Some((section.section_y as i32) * 16 + 15); + break; + } + } + + let tile_local_x = chunk_local_x * 16 + lx; + let tile_local_z = chunk_local_z * 16 + lz; + + if tile_local_x < 128 + && tile_local_z < 128 + && let Some(y) = surface_y + { + let clamped = + (y as i16).clamp(tile.min_height, tile.max_height); + tile.heightmap[tile_local_z * 128 + tile_local_x] = clamped; + } + } + } + } + } + } + } + } + + let dimension = Dimension { + name: "overworld".to_string(), + tiles, + min_height: platform.min_height, + max_height: platform.max_height, + seed, + }; + + Ok(World { + name: world_name, + platform, + dimensions: vec![dimension], + seed, + }) +} + +fn parse_level_dat(root: &terrafier_nbt::Tag) -> (String, u64, i32) { + let default = || ("Unknown".to_string(), 0u64, 3954i32); + match root { + terrafier_nbt::Tag::Compound(map) => { + let data = match map.get("Data") { + Some(terrafier_nbt::Tag::Compound(d)) => d, + _ => return default(), + }; + let name = match data.get("LevelName") { + Some(terrafier_nbt::Tag::String(s)) => s.clone(), + _ => "Unknown".to_string(), + }; + let seed = match data.get("WorldGenSettings").and_then(|w| match w { + terrafier_nbt::Tag::Compound(m) => m.get("seed"), + _ => None, + }) { + Some(terrafier_nbt::Tag::Long(s)) => *s as u64, + _ => 0, + }; + let data_version = match data.get("DataVersion") { + Some(terrafier_nbt::Tag::Int(v)) => *v, + _ => 3954, + }; + (name, seed, data_version) + } + _ => default(), + } +} + +pub fn discover_region_files(world_path: &Path) -> Result> { + let region_dir = world_path.join("region"); + if !region_dir.is_dir() { + return Err(MinecraftIOError::MissingRegionDir); + } + + let mut files = Vec::new(); + for entry in fs::read_dir(®ion_dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().is_some_and(|e| e == "mca") { + files.push(path); + } + } + files.sort(); + Ok(files) +} diff --git a/core/src/io/minecraft/version.rs b/core/src/io/minecraft/version.rs new file mode 100644 index 0000000..6c5f712 --- /dev/null +++ b/core/src/io/minecraft/version.rs @@ -0,0 +1,97 @@ +//! Minecraft data version constants and version detection. + +use crate::model::types::Platform; + +pub fn data_version() -> i32 { + 3954 +} + +pub fn version_from_data_version(dv: i32) -> Option { + match dv { + 2860..=2865 => Some(Platform { + id: "java_1_18".into(), + display_name: "Minecraft Java 1.18".into(), + min_height: -64, + max_height: 320, + }), + 2866..=2974 => { + log::warn!( + "DataVersion {} is between 1.18 and 1.19, falling back to 1.18", + dv + ); + Some(Platform { + id: "java_1_18".into(), + display_name: "Minecraft Java 1.18".into(), + min_height: -64, + max_height: 320, + }) + } + 2975..=3117 => Some(Platform { + id: "java_1_19".into(), + display_name: "Minecraft Java 1.19".into(), + min_height: -64, + max_height: 320, + }), + 3118..=3336 => { + log::warn!( + "DataVersion {} is between 1.19 and 1.20, falling back to 1.19", + dv + ); + Some(Platform { + id: "java_1_19".into(), + display_name: "Minecraft Java 1.19".into(), + min_height: -64, + max_height: 320, + }) + } + 3337..=3460 => Some(Platform { + id: "java_1_20".into(), + display_name: "Minecraft Java 1.20".into(), + min_height: -64, + max_height: 320, + }), + 3461..=3577 => { + log::warn!( + "DataVersion {} is between 1.20 and 1.20.5, falling back to 1.20", + dv + ); + Some(Platform { + id: "java_1_20".into(), + display_name: "Minecraft Java 1.20 (fallback)".into(), + min_height: -64, + max_height: 320, + }) + } + 3578..=3700 => Some(Platform { + id: "java_1_20_5".into(), + display_name: "Minecraft Java 1.20.5+".into(), + min_height: -64, + max_height: 320, + }), + 3701..=3818 => { + log::warn!( + "DataVersion {} is between 1.20.5 and 1.21, falling back to 1.20.5", + dv + ); + Some(Platform { + id: "java_1_20_5".into(), + display_name: "Minecraft Java 1.20.5 (fallback)".into(), + min_height: -64, + max_height: 320, + }) + } + 3819..=3953 => Some(Platform { + id: "java_1_21".into(), + display_name: "Minecraft Java 1.21".into(), + min_height: -64, + max_height: 320, + }), + 3954..=4100 => Some(Platform { + id: "java_1_21_2".into(), + display_name: "Minecraft Java 1.21.2+".into(), + min_height: -64, + max_height: 320, + }), + _ => None, + } +} diff --git a/core/src/io/mod.rs b/core/src/io/mod.rs index c210cb3..c66cec9 100755 --- a/core/src/io/mod.rs +++ b/core/src/io/mod.rs @@ -1,8 +1,53 @@ //! I/O module — Minecraft save import/export, rendering, and binary save/load. -pub mod binary; pub mod export; pub mod import; pub mod layer_export; pub mod minecraft; pub mod wp_import; + +use std::path::Path; + +use crate::model::world::World; + +#[derive(thiserror::Error, Debug)] +pub enum BinaryError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("Bincode serialize error: {0}")] + Serialize(String), + #[error("Bincode deserialize error: {0}")] + Deserialize(String), + #[error("Validation failed: {0}")] + Validation(String), +} + +pub type Result = std::result::Result; + +/// Save a `World` to a binary file. +pub fn save_binary(world: &World, path: &Path) -> Result<()> { + let bytes = bincode::serialize(world).map_err(|e| BinaryError::Serialize(e.to_string()))?; + + std::fs::write(path, &bytes)?; + Ok(()) +} + +/// Load a `World` from a binary file. +pub fn load_binary(path: &Path) -> Result { + let bytes = std::fs::read(path)?; + let world: World = + bincode::deserialize(&bytes).map_err(|e| BinaryError::Deserialize(e.to_string()))?; + Ok(world) +} + +/// Validate a binary world file by loading and discarding. +pub fn validate_binary(path: &Path) -> Result<()> { + if !path.exists() { + return Err(BinaryError::Validation(format!( + "File not found: {}", + path.display() + ))); + } + let _world = load_binary(path)?; + Ok(()) +} diff --git a/core/src/io/wp_import.rs b/core/src/io/wp_import.rs deleted file mode 100644 index e1b914f..0000000 --- a/core/src/io/wp_import.rs +++ /dev/null @@ -1,1052 +0,0 @@ -//! WorldPainter .world file import. -//! -//! Reads WorldPainter `.world` files, which use Java serialization format. -//! Implements a minimal Java ObjectInputStream parser sufficient for extracting -//! tile heightmap, terrain, and water level data from WorldPainter worlds. - -use std::collections::HashMap; -use std::io::Read; -use std::path::{Path, PathBuf}; -use thiserror::Error; - -use crate::model::dimension::Dimension; -use crate::model::platform::Platform; -use crate::model::tile::Tile; -use crate::model::world::World; - -// --------------------------------------------------------------------------- -// Error types -// --------------------------------------------------------------------------- - -#[derive(Error, Debug)] -pub enum WpImportError { - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - #[error("Invalid WorldPainter file: {0}")] - InvalidFormat(String), - #[error("Unsupported WorldPainter version: {0}")] - UnsupportedVersion(i32), - #[error("Compression error: {0}")] - Compression(String), - #[error("Missing data: {0}")] - MissingData(String), -} - -pub type Result = std::result::Result; - -// --------------------------------------------------------------------------- -// Java serialisation stream constants -// --------------------------------------------------------------------------- - -const TC_NULL: u8 = 0x70; -const TC_REFERENCE: u8 = 0x71; -const TC_CLASSDESC: u8 = 0x72; -const TC_OBJECT: u8 = 0x73; -const TC_STRING: u8 = 0x74; -const TC_ARRAY: u8 = 0x75; -const TC_CLASS: u8 = 0x76; -const TC_BLOCKDATA: u8 = 0x77; -const TC_ENDBLOCKDATA: u8 = 0x78; -const TC_RESET: u8 = 0x79; -const TC_BLOCKDATALONG: u8 = 0x7A; -const TC_LONGSTRING: u8 = 0x7C; -const TC_PROXYCLASSDESC: u8 = 0x7D; -const TC_ENUM: u8 = 0x7E; - -const SC_WRITE_METHOD: u8 = 0x01; - -const BASE_WIRE_HANDLE: u32 = 0x7E0000; - -const PRIM_BYTE: u8 = b'B'; -const PRIM_CHAR: u8 = b'C'; -const PRIM_DOUBLE: u8 = b'D'; -const PRIM_FLOAT: u8 = b'F'; -const PRIM_INT: u8 = b'I'; -const PRIM_LONG: u8 = b'J'; -const PRIM_SHORT: u8 = b'S'; -const PRIM_BOOL: u8 = b'Z'; - -// --------------------------------------------------------------------------- -// Java serialisation stream reader -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone)] -struct FieldInfo { - name: String, - type_code: u8, -} - -#[derive(Debug, Clone)] -struct ClassDesc { - name: String, - flags: u8, - fields: Vec, -} - -#[derive(Debug, Clone)] -enum JvmValue { - Null, - String(String), - ClassDesc(ClassDesc), - Object(HashMap), - ByteArray(Vec), - ShortArray(Vec), - IntArray(Vec), - LongArray(()), - Int(i32), - Long(i64), - Skipped, -} - -impl JvmValue { - fn as_string(&self) -> Option { - match self { - JvmValue::String(s) => Some(s.clone()), - JvmValue::Object(map) => { - for key in &["name", "key", "id", "value"] { - if let Some(val) = map.get(*key) - && let JvmValue::String(s) = val - { - return Some(s.clone()); - } - } - None - } - _ => None, - } - } - - fn as_i32(&self) -> Option { - match self { - JvmValue::Int(v) => Some(*v), - _ => None, - } - } - - fn as_u64(&self) -> Option { - match self { - JvmValue::Long(v) => Some(*v as u64), - JvmValue::Int(v) => Some(*v as u64), - _ => None, - } - } - - fn as_object(&self) -> Option<&HashMap> { - match self { - JvmValue::Object(m) => Some(m), - _ => None, - } - } -} - -struct JvmStream { - data: Vec, - pos: usize, - handles: Vec, - class_descs: Vec, -} - -impl JvmStream { - fn new(data: Vec) -> Self { - JvmStream { - data, - pos: 0, - handles: Vec::new(), - class_descs: Vec::new(), - } - } - - fn read_byte(&mut self) -> std::io::Result { - if self.pos >= self.data.len() { - return Err(std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, - "unexpected end of stream", - )); - } - let b = self.data[self.pos]; - self.pos += 1; - Ok(b) - } - - fn read_bytes(&mut self, n: usize) -> std::io::Result> { - if self.pos + n > self.data.len() { - return Err(std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, - "unexpected end of stream", - )); - } - let slice = self.data[self.pos..self.pos + n].to_vec(); - self.pos += n; - Ok(slice) - } - - fn peek_byte(&self) -> std::io::Result { - if self.pos >= self.data.len() { - return Err(std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, - "unexpected end of stream", - )); - } - Ok(self.data[self.pos]) - } - - fn read_u16(&mut self) -> std::io::Result { - let hi = self.read_byte()? as u16; - let lo = self.read_byte()? as u16; - Ok((hi << 8) | lo) - } - - fn read_i32(&mut self) -> std::io::Result { - let b1 = self.read_byte()? as i32; - let b2 = self.read_byte()? as i32; - let b3 = self.read_byte()? as i32; - let b4 = self.read_byte()? as i32; - Ok((b1 << 24) | (b2 << 16) | (b3 << 8) | b4) - } - - fn read_i64(&mut self) -> std::io::Result { - let b1 = self.read_byte()? as i64; - let b2 = self.read_byte()? as i64; - let b3 = self.read_byte()? as i64; - let b4 = self.read_byte()? as i64; - let b5 = self.read_byte()? as i64; - let b6 = self.read_byte()? as i64; - let b7 = self.read_byte()? as i64; - let b8 = self.read_byte()? as i64; - Ok((b1 << 56) - | (b2 << 48) - | (b3 << 40) - | (b4 << 32) - | (b5 << 24) - | (b6 << 16) - | (b7 << 8) - | b8) - } - - fn read_f32(&mut self) -> std::io::Result { - Ok(f32::from_bits(self.read_i32()? as u32)) - } - - fn read_f64(&mut self) -> std::io::Result { - Ok(f64::from_bits(self.read_i64()? as u64)) - } - - fn read_utf(&mut self) -> std::io::Result { - let len = self.read_u16()? as usize; - let bytes = self.read_bytes(len)?; - Ok(String::from_utf8_lossy(&bytes).to_string()) - } - - fn read_long_utf(&mut self) -> std::io::Result { - let len = self.read_i64()? as usize; - let bytes = self.read_bytes(len)?; - Ok(String::from_utf8_lossy(&bytes).to_string()) - } - - fn read_content(&mut self) -> std::io::Result { - loop { - if self.pos >= self.data.len() { - return Ok(JvmValue::Null); - } - let tc = self.read_byte()?; - match tc { - TC_OBJECT => return self.read_object(), - TC_ARRAY => return self.read_array(), - TC_STRING => { - let s = self.read_utf()?; - self.push_handle(JvmValue::String(s.clone())); - return Ok(JvmValue::String(s)); - } - TC_LONGSTRING => { - let s = self.read_long_utf()?; - self.push_handle(JvmValue::String(s.clone())); - return Ok(JvmValue::String(s)); - } - TC_REFERENCE => { - let handle = self.read_u32_handle(); - let idx = (handle - BASE_WIRE_HANDLE) as usize; - if idx < self.handles.len() { - let val = self.handles[idx].clone(); - return Ok(val); - } - return Ok(JvmValue::Null); - } - TC_NULL => return Ok(JvmValue::Null), - TC_CLASS => { - let _desc = self.read_classdesc()?; - return Ok(JvmValue::Skipped); - } - TC_ENUM => { - let _desc = self.read_classdesc()?; - let _ordinal = self.read_i32()?; - return Ok(JvmValue::Skipped); - } - TC_BLOCKDATA => { - let len = self.read_byte()? as usize; - self.pos += len; - } - TC_BLOCKDATALONG => { - let len = self.read_i32()? as usize; - self.pos += len; - } - TC_RESET => { - self.handles.clear(); - } - _ => { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("unexpected content token: 0x{:02x}", tc), - )); - } - } - } - } - - // ----------------------------------------------------------------------- - // Handle management - // ----------------------------------------------------------------------- - - fn push_handle(&mut self, val: JvmValue) -> u32 { - let idx = self.handles.len(); - self.handles.push(val); - BASE_WIRE_HANDLE + idx as u32 - } - - fn read_u32_handle(&mut self) -> u32 { - let mut buf = [0u8; 4]; - buf.copy_from_slice(&self.data[self.pos..self.pos + 4]); - self.pos += 4; - u32::from_be_bytes(buf) - } - - // ----------------------------------------------------------------------- - // Class descriptor parsing - // ----------------------------------------------------------------------- - - fn read_classdesc(&mut self) -> std::io::Result { - let tc = self.peek_byte()?; - match tc { - TC_CLASSDESC => self.read_classdesc_body(), - TC_REFERENCE => { - self.read_byte()?; - let handle = self.read_u32_handle(); - let idx = (handle - BASE_WIRE_HANDLE) as usize; - if idx < self.handles.len() { - Ok(self.handles[idx].clone()) - } else { - Ok(JvmValue::Null) - } - } - TC_NULL => { - self.read_byte()?; - Ok(JvmValue::Null) - } - TC_PROXYCLASSDESC => { - self.read_byte()?; - let _iface_count = self.read_i32()?; - for _ in 0.._iface_count { - let _iface = self.read_utf()?; - } - self.read_class_annotations()?; - let _super = self.read_classdesc()?; - self.push_handle(JvmValue::Skipped); - Ok(JvmValue::Skipped) - } - _ => Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("expected classdesc token, got 0x{:02x}", tc), - )), - } - } - - fn read_classdesc_body(&mut self) -> std::io::Result { - self.read_byte()?; // consume TC_CLASSDESC - let class_name = self.read_utf()?; - let _serial_version_uid = self.read_i64()?; - let desc_flags = self.read_byte()?; - let field_count = self.read_u16()?; - - let mut fields = Vec::new(); - for _ in 0..field_count { - let type_code = self.read_byte()?; - let name = self.read_utf()?; - if type_code == b'[' || type_code == b'L' { - let tc = self.read_byte()?; - match tc { - TC_STRING => { - self.read_utf()?; - } - TC_REFERENCE => { - let handle = self.read_u32_handle(); - let idx = (handle - BASE_WIRE_HANDLE) as usize; - if idx < self.handles.len() { - let _ = self.handles[idx].as_string(); - } - } - TC_LONGSTRING => { - self.read_long_utf()?; - } - _ => {} - } - } - fields.push(FieldInfo { name, type_code }); - } - - let cd = ClassDesc { - name: class_name.clone(), - flags: desc_flags, - fields, - }; - self.class_descs.push(cd.clone()); - - // Push handle for this class descriptor - self.push_handle(JvmValue::ClassDesc(cd.clone())); - - // Class annotations - self.read_class_annotations()?; - - // Super class descriptor - let _super = self.read_classdesc()?; - - Ok(JvmValue::ClassDesc(cd)) - } - - fn read_class_annotations(&mut self) -> std::io::Result<()> { - loop { - let tc = self.read_byte()?; - match tc { - TC_ENDBLOCKDATA => return Ok(()), - TC_BLOCKDATA => { - let len = self.read_byte()? as usize; - self.pos += len; - } - TC_BLOCKDATALONG => { - let len = self.read_i32()? as usize; - self.pos += len; - } - TC_OBJECT | TC_ARRAY | TC_STRING | TC_LONGSTRING | TC_ENUM => { - self.pos -= 1; // push back, let read_content handle it - let _val = self.read_content()?; - } - TC_REFERENCE => { - let _handle = self.read_u32_handle(); - } - TC_CLASSDESC | TC_PROXYCLASSDESC => { - self.pos -= 1; - let _desc = self.read_classdesc()?; - } - TC_NULL => {} - TC_CLASS => { - let _desc = self.read_classdesc()?; - } - TC_RESET => { - self.handles.clear(); - } - other => { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("unexpected token in class annotations: 0x{:02x}", other), - )); - } - } - } - } - - // ----------------------------------------------------------------------- - // Object parsing - // ----------------------------------------------------------------------- - - fn read_object(&mut self) -> std::io::Result { - let class_desc_val = self.read_classdesc()?; - let cd = match &class_desc_val { - JvmValue::ClassDesc(cd) => cd.clone(), - JvmValue::Null => { - // Null classdesc means no fields - return Ok(JvmValue::Object(HashMap::new())); - } - _ => { - // Try to find field info from known classes - let name = class_desc_val.as_string().unwrap_or_default(); - let cd = self.resolve_class_desc(&name); - match cd { - Some(cd) => cd, - None => { - // Unknown class, skip object data - let _handle = self.push_handle(JvmValue::Skipped); - self.skip_object_data(&name)?; - return Ok(JvmValue::Skipped); - } - } - } - }; - - let handle = self.push_handle(JvmValue::Skipped); - - let obj = match cd.name.as_str() { - "java.util.HashMap" | "java.util.LinkedHashMap" => self.read_hashmap_data()?, - "java.util.HashSet" | "java.util.LinkedHashSet" => self.read_hashset_data()?, - "java.util.ArrayList" => self.read_arraylist_data()?, - "java.lang.Integer" => { - let val = self.read_i32()?; - self.handles[(handle - BASE_WIRE_HANDLE) as usize] = JvmValue::Int(val); - return Ok(JvmValue::Int(val)); - } - "java.lang.Long" => { - let val = self.read_i64()?; - self.handles[(handle - BASE_WIRE_HANDLE) as usize] = JvmValue::Long(val); - return Ok(JvmValue::Long(val)); - } - "java.lang.String" => { - let val = self.read_content()?; - self.handles[(handle - BASE_WIRE_HANDLE) as usize] = val.clone(); - return Ok(val); - } - _ => self.read_object_fields(&cd)?, - }; - - self.handles[(handle - BASE_WIRE_HANDLE) as usize] = obj.clone(); - Ok(obj) - } - - fn resolve_class_desc(&self, name: &str) -> Option { - // Exact match first - for cd in &self.class_descs { - if cd.name == name { - return Some(cd.clone()); - } - } - // Try known WorldPainter class mappings - None - } - - fn skip_object_data(&mut self, _class_name: &str) -> std::io::Result<()> { - // Try to read and discard using class annotations - // This is a best-effort skip - let saved = self.pos; - match self.read_class_annotations() { - Ok(()) => Ok(()), - Err(_) => { - self.pos = saved; - // Try reading block data style - if self.pos < self.data.len() { - let tc = match self.peek_byte() { - Ok(t) => t, - Err(_) => return Ok(()), - }; - if tc == TC_BLOCKDATA || tc == TC_BLOCKDATALONG { - return self.read_class_annotations(); - } - // Try to skip a reasonable amount - let _remaining = self.data.len() - self.pos; - self.pos = self.data.len(); - Ok(()) - } else { - Ok(()) - } - } - } - } - - fn read_object_fields(&mut self, cd: &ClassDesc) -> std::io::Result { - let mut map = HashMap::new(); - - // Read fields for this class - for field in &cd.fields { - let val = self.read_object_field(field)?; - map.insert(field.name.clone(), val); - } - - // Handle any class annotations (for writeObject) - if (cd.flags & SC_WRITE_METHOD) != 0 { - self.read_class_annotations()?; - } - - // Read superclass data if any - // For the class hierarchy, read field data from superclasses - // We can find the superclass descriptor from the stored class_descs - let super_name = self.find_super_class_name(&cd.name); - if let Some(super_name) = super_name - && !super_name.is_empty() - && super_name != "java.lang.Object" - && let Some(super_cd) = self.resolve_class_desc(&super_name) - { - let super_fields = self.read_object_fields(&super_cd)?; - if let JvmValue::Object(super_map) = super_fields { - map.extend(super_map); - } - } - - Ok(JvmValue::Object(map)) - } - - fn find_super_class_name(&self, _class_name: &str) -> Option { - // In a real implementation, we'd track superclass relationships. - // For WorldPainter, the classes generally directly extend Object. - // We'll use the static map for known class hierarchies. - None - } - - fn read_object_field(&mut self, field: &FieldInfo) -> std::io::Result { - match field.type_code { - PRIM_BYTE => Ok(JvmValue::Int(self.read_byte()? as i32)), - PRIM_SHORT => Ok(JvmValue::Int(self.read_u16()? as i16 as i32)), - PRIM_INT => Ok(JvmValue::Int(self.read_i32()?)), - PRIM_LONG => Ok(JvmValue::Long(self.read_i64()?)), - PRIM_FLOAT => { - let v = self.read_f32()?; - Ok(JvmValue::Int(v as i32)) - } - PRIM_DOUBLE => { - let v = self.read_f64()?; - Ok(JvmValue::Long(v as i64)) - } - PRIM_BOOL => Ok(JvmValue::Int(self.read_byte()? as i32)), - PRIM_CHAR => Ok(JvmValue::Int(self.read_u16()? as i32)), - b'L' | b'[' => { - // Object or array reference - self.read_content() - } - _ => { - // Unknown type, try to read as object - self.read_content() - } - } - } - - // ----------------------------------------------------------------------- - // Array parsing - // ----------------------------------------------------------------------- - - fn read_array(&mut self) -> std::io::Result { - let class_desc_val = self.read_classdesc()?; - let class_name = class_desc_val.as_string().unwrap_or_default(); - - let length = self.read_i32()? as usize; - - if length == 0 { - self.push_handle(JvmValue::Skipped); - return Ok(JvmValue::Skipped); - } - - let elem_type = class_name.as_bytes().get(1).copied(); - - let result = match elem_type { - Some(PRIM_BYTE) => { - let bytes = self.read_bytes(length)?; - let arr: Vec = bytes.iter().map(|&b| b as i8).collect(); - JvmValue::ByteArray(arr) - } - Some(PRIM_SHORT) => { - let mut arr = Vec::with_capacity(length); - for _ in 0..length { - arr.push(self.read_u16()? as i16); - } - JvmValue::ShortArray(arr) - } - Some(PRIM_INT) => { - let mut arr = Vec::with_capacity(length); - for _ in 0..length { - arr.push(self.read_i32()?); - } - JvmValue::IntArray(arr) - } - Some(PRIM_LONG) => { - for _ in 0..length { - let _ = self.read_i64()?; - } - JvmValue::LongArray(()) - } - _ => { - // Object array — read elements individually - for _ in 0..length { - let _elem = self.read_content()?; - } - JvmValue::Skipped - } - }; - - self.push_handle(result.clone()); - Ok(result) - } - - // ----------------------------------------------------------------------- - // Collection parsers - // ----------------------------------------------------------------------- - - fn read_hashmap_data(&mut self) -> std::io::Result { - // HashMap reads: capacity (int), loadFactor (int as float*10000), size (int), - // then key-value pairs (object, object) - let _capacity = self.read_i32()?; - let _load_factor = self.read_i32()?; - let _threshold = self.read_i32()?; - let size = self.read_i32()?; - - let mut map = HashMap::new(); - for _ in 0..size { - let key = self.read_content()?; - let value = self.read_content()?; - let key_str = key - .as_string() - .unwrap_or_else(|| format!("__key_{}", map.len())); - map.insert(key_str, value); - } - - Ok(JvmValue::Object(map)) - } - - fn read_hashset_data(&mut self) -> std::io::Result { - let _capacity = self.read_i32()?; - let _load_factor = self.read_i32()?; - let _threshold = self.read_i32()?; - let size = self.read_i32()?; - - let mut map = HashMap::new(); - for i in 0..size { - let val = self.read_content()?; - let key = val.as_string().unwrap_or_else(|| format!("__elem_{}", i)); - map.insert(key, val); - } - - Ok(JvmValue::Object(map)) - } - - fn read_arraylist_data(&mut self) -> std::io::Result { - let size = self.read_i32()?; - - let mut map = HashMap::new(); - for i in 0..size { - let val = self.read_content()?; - let key = val.as_string().unwrap_or_else(|| format!("__elem_{}", i)); - map.insert(key, val); - } - - Ok(JvmValue::Object(map)) - } -} - -// --------------------------------------------------------------------------- -// WorldPainter data extraction from parsed JvmValues -// --------------------------------------------------------------------------- - -struct WpTileData { - x: i32, - z: i32, - heightmap: Option<[i16; 16384]>, - terrain: Option<[u8; 16384]>, - water_level: Option<[u8; 16384]>, -} - -struct WpWorldData { - name: String, - seed: u64, - tiles: Vec, -} - -fn extract_world_data(val: &JvmValue) -> Result { - let obj = val.as_object().ok_or_else(|| { - WpImportError::InvalidFormat("root object is not a compound object".into()) - })?; - - let name = obj - .get("name") - .and_then(|v| v.as_string()) - .unwrap_or_else(|| "WorldPainter World".to_string()); - - let seed = obj.get("seed").and_then(|v| v.as_u64()).unwrap_or(0); - - let mut tiles_data = Vec::new(); - - // Find tiles — could be in "tiles" field, or nested - if let Some(tiles_val) = obj.get("tiles") { - collect_tiles(tiles_val, &mut tiles_data); - } - - // Also check for tiles in "dimensions" or other container fields - for key in &["dimensions", "tileSet", "worldTiles"] { - if let Some(val) = obj.get(*key) { - collect_tiles(val, &mut tiles_data); - } - } - - // Fallback: scan all values for tile-like objects - if tiles_data.is_empty() { - for (_key, val) in obj.iter() { - collect_tiles(val, &mut tiles_data); - } - } - - Ok(WpWorldData { - name, - seed, - tiles: tiles_data, - }) -} - -fn collect_tiles(val: &JvmValue, tiles: &mut Vec) { - match val { - JvmValue::Object(map) => { - // Check if this is a tile by looking for heightMap/terrain fields - if (map.contains_key("heightMap") - || map.contains_key("terrain") - || map.contains_key("waterLevel")) - && let Some(tile) = parse_single_tile(val) - { - tiles.push(tile); - return; - } - // Could be a map from tile-key (Long) to Tile - for (_key, sub) in map.iter() { - collect_tiles(sub, tiles); - } - } - JvmValue::ByteArray(_) - | JvmValue::ShortArray(_) - | JvmValue::IntArray(_) - | JvmValue::LongArray(_) => {} - JvmValue::Skipped => {} - _ => {} - } -} - -fn parse_single_tile(val: &JvmValue) -> Option { - let map = val.as_object()?; - let x = map.get("x").and_then(|v| v.as_i32()).unwrap_or(0); - let z = map.get("y").and_then(|v| v.as_i32()).unwrap_or(0); - - let heightmap = map.get("heightMap").and_then(array_to_heightmap); - let terrain = map.get("terrain").and_then(array_to_u8_16384); - let water_level = map.get("waterLevel").and_then(array_to_u8_16384); - - Some(WpTileData { - x, - z, - heightmap, - terrain, - water_level, - }) -} - -fn array_to_heightmap(val: &JvmValue) -> Option<[i16; 16384]> { - match val { - JvmValue::ShortArray(arr) => { - if arr.len() >= 16384 { - let mut result = [0i16; 16384]; - for (i, &v) in arr.iter().enumerate().take(16384) { - result[i] = v; - } - Some(result) - } else { - None - } - } - JvmValue::IntArray(arr) => { - if arr.len() >= 16384 { - let mut result = [0i16; 16384]; - for (i, &v) in arr.iter().enumerate().take(16384) { - result[i] = v.clamp(i16::MIN as i32, i16::MAX as i32) as i16; - } - Some(result) - } else { - None - } - } - _ => None, - } -} - -fn array_to_u8_16384(val: &JvmValue) -> Option<[u8; 16384]> { - match val { - JvmValue::ByteArray(arr) => { - if arr.len() >= 16384 { - let mut result = [0u8; 16384]; - for (i, &v) in arr.iter().enumerate().take(16384) { - result[i] = v as u8; - } - Some(result) - } else { - None - } - } - JvmValue::IntArray(arr) => { - if arr.len() >= 16384 { - let mut result = [0u8; 16384]; - for (i, &v) in arr.iter().enumerate().take(16384) { - result[i] = v as u8; - } - Some(result) - } else { - None - } - } - JvmValue::ShortArray(arr) => { - if arr.len() >= 16384 { - let mut result = [0u8; 16384]; - for (i, &v) in arr.iter().enumerate().take(16384) { - result[i] = v as u8; - } - Some(result) - } else { - None - } - } - _ => None, - } -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -pub struct WpImporter { - path: PathBuf, -} - -impl WpImporter { - pub fn new(path: impl Into) -> Self { - Self { path: path.into() } - } - - pub fn import(&self) -> Result { - import_world_file(&self.path) - } -} - -/// Check whether a path looks like a WorldPainter .world file. -pub fn is_world_file(path: &Path) -> bool { - path.extension().is_some_and(|ext| ext == "world") - || path - .file_name() - .is_some_and(|name| name.to_string_lossy().ends_with(".world")) -} - -/// Import a WorldPainter .world file into a Terrafier World model. -pub fn import_world_file(path: &Path) -> Result { - let raw = std::fs::read(path).map_err(WpImportError::Io)?; - - if raw.is_empty() { - return Err(WpImportError::InvalidFormat("file is empty".into())); - } - - // Try to decompress if GZip - let data = if raw.len() >= 2 && raw[0] == 0x1F && raw[1] == 0x8B { - let mut decoder = flate2::read::GzDecoder::new(&raw[..]); - let mut decompressed = Vec::new(); - decoder - .read_to_end(&mut decompressed) - .map_err(|e| WpImportError::Compression(e.to_string()))?; - decompressed - } else { - raw - }; - - // Validate Java serialization magic - if data.len() < 4 { - return Err(WpImportError::InvalidFormat( - "file too small after decompression".into(), - )); - } - let magic = u16::from_be_bytes([data[0], data[1]]); - if magic != 0xACED { - return Err(WpImportError::InvalidFormat(format!( - "not a Java serialization stream (expected magic ACED, got {:04X})", - magic - ))); - } - let stream_version = u16::from_be_bytes([data[2], data[3]]); - if stream_version != 5 { - return Err(WpImportError::InvalidFormat(format!( - "unsupported Java serialization stream version: {}", - stream_version - ))); - } - - // Parse the Java serialization stream - let mut stream = JvmStream::new(data); - - // Read the stream header content (the first object) - let root = stream - .read_content() - .map_err(|e| WpImportError::InvalidFormat(e.to_string()))?; - - // Extract world data from parsed stream - let world_data = extract_world_data(&root)?; - - if world_data.tiles.is_empty() && !world_data.name.is_empty() { - // We at least got a name — return an empty world - } - - let platform = Platform::java_1_18(); - - // Build tiles - let mut tiles = std::collections::HashMap::new(); - for wp_tile in &world_data.tiles { - let mut tile = Tile::new( - wp_tile.x, - wp_tile.z, - platform.min_height, - platform.max_height, - ); - - if let Some(hm) = &wp_tile.heightmap { - tile.heightmap = *hm; - } - if let Some(ter) = &wp_tile.terrain { - tile.terrain = *ter; - } - if let Some(wl) = &wp_tile.water_level { - tile.water_level = *wl; - } - - tiles.insert((wp_tile.x, wp_tile.z), tile); - } - - let dimension = Dimension { - name: "overworld".to_string(), - tiles, - min_height: platform.min_height, - max_height: platform.max_height, - seed: world_data.seed, - }; - - Ok(World { - name: world_data.name, - platform, - dimensions: vec![dimension], - seed: world_data.seed, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - - #[test] - fn test_no_file() { - let result = import_world_file(Path::new("/nonexistent/magic.world")); - assert!(result.is_err()); - } - - #[test] - fn test_invalid_format() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("test.world"); - let mut f = std::fs::File::create(&path).unwrap(); - f.write_all(b"not a world file").unwrap(); - let result = import_world_file(&path); - assert!(result.is_err()); - } - - #[test] - fn test_not_world() { - let result = import_world_file(Path::new("/tmp")); - assert!(result.is_err()); - } - - #[test] - fn test_is_world_file() { - assert!(is_world_file(Path::new("test.world"))); - assert!(!is_world_file(Path::new("test.mca"))); - assert!(!is_world_file(Path::new("level.dat"))); - } -} diff --git a/core/src/io/wp_import/extract.rs b/core/src/io/wp_import/extract.rs new file mode 100644 index 0000000..2dcbd75 --- /dev/null +++ b/core/src/io/wp_import/extract.rs @@ -0,0 +1,162 @@ +use super::types::WpImportError; +use super::types::{JvmValue, Result}; + +pub struct WpTileData { + pub x: i32, + pub z: i32, + pub heightmap: Option<[i16; 16384]>, + pub terrain: Option<[u8; 16384]>, + pub water_level: Option<[u8; 16384]>, +} + +pub struct WpWorldData { + pub name: String, + pub seed: u64, + pub tiles: Vec, +} + +pub fn extract_world_data(val: &JvmValue) -> Result { + let obj = val.as_object().ok_or_else(|| { + WpImportError::InvalidFormat("root object is not a compound object".into()) + })?; + + let name = obj + .get("name") + .and_then(|v| v.as_string()) + .unwrap_or_else(|| "WorldPainter World".to_string()); + + let seed = obj.get("seed").and_then(|v| v.as_u64()).unwrap_or(0); + + let mut tiles_data = Vec::new(); + + if let Some(tiles_val) = obj.get("tiles") { + collect_tiles(tiles_val, &mut tiles_data); + } + + for key in &["dimensions", "tileSet", "worldTiles"] { + if let Some(val) = obj.get(*key) { + collect_tiles(val, &mut tiles_data); + } + } + + if tiles_data.is_empty() { + for (_key, val) in obj.iter() { + collect_tiles(val, &mut tiles_data); + } + } + + Ok(WpWorldData { + name, + seed, + tiles: tiles_data, + }) +} + +fn collect_tiles(val: &JvmValue, tiles: &mut Vec) { + match val { + JvmValue::Object(map) => { + if (map.contains_key("heightMap") + || map.contains_key("terrain") + || map.contains_key("waterLevel")) + && let Some(tile) = parse_single_tile(val) + { + tiles.push(tile); + return; + } + for (_key, sub) in map.iter() { + collect_tiles(sub, tiles); + } + } + JvmValue::ByteArray(_) + | JvmValue::ShortArray(_) + | JvmValue::IntArray(_) + | JvmValue::LongArray(_) => {} + JvmValue::Skipped => {} + _ => {} + } +} + +fn parse_single_tile(val: &JvmValue) -> Option { + let map = val.as_object()?; + let x = map.get("x").and_then(|v| v.as_i32()).unwrap_or(0); + let z = map.get("y").and_then(|v| v.as_i32()).unwrap_or(0); + + let heightmap = map.get("heightMap").and_then(array_to_heightmap); + let terrain = map.get("terrain").and_then(array_to_u8_16384); + let water_level = map.get("waterLevel").and_then(array_to_u8_16384); + + Some(WpTileData { + x, + z, + heightmap, + terrain, + water_level, + }) +} + +fn array_to_heightmap(val: &JvmValue) -> Option<[i16; 16384]> { + match val { + JvmValue::ShortArray(arr) => { + if arr.len() >= 16384 { + let mut result = [0i16; 16384]; + for (i, &v) in arr.iter().enumerate().take(16384) { + result[i] = v; + } + Some(result) + } else { + None + } + } + JvmValue::IntArray(arr) => { + if arr.len() >= 16384 { + let mut result = [0i16; 16384]; + for (i, &v) in arr.iter().enumerate().take(16384) { + result[i] = v.clamp(i16::MIN as i32, i16::MAX as i32) as i16; + } + Some(result) + } else { + None + } + } + _ => None, + } +} + +fn array_to_u8_16384(val: &JvmValue) -> Option<[u8; 16384]> { + match val { + JvmValue::ByteArray(arr) => { + if arr.len() >= 16384 { + let mut result = [0u8; 16384]; + for (i, &v) in arr.iter().enumerate().take(16384) { + result[i] = v as u8; + } + Some(result) + } else { + None + } + } + JvmValue::IntArray(arr) => { + if arr.len() >= 16384 { + let mut result = [0u8; 16384]; + for (i, &v) in arr.iter().enumerate().take(16384) { + result[i] = v as u8; + } + Some(result) + } else { + None + } + } + JvmValue::ShortArray(arr) => { + if arr.len() >= 16384 { + let mut result = [0u8; 16384]; + for (i, &v) in arr.iter().enumerate().take(16384) { + result[i] = v as u8; + } + Some(result) + } else { + None + } + } + _ => None, + } +} diff --git a/core/src/io/wp_import/java_reader.rs b/core/src/io/wp_import/java_reader.rs new file mode 100644 index 0000000..1519468 --- /dev/null +++ b/core/src/io/wp_import/java_reader.rs @@ -0,0 +1,180 @@ +use super::types::{ + ClassDesc, JvmValue, BASE_WIRE_HANDLE, TC_ARRAY, TC_BLOCKDATA, TC_BLOCKDATALONG, TC_CLASS, + TC_ENUM, TC_LONGSTRING, TC_NULL, TC_OBJECT, TC_REFERENCE, TC_RESET, TC_STRING, +}; + +pub struct JvmStream { + pub data: Vec, + pub pos: usize, + pub handles: Vec, + pub class_descs: Vec, +} + +impl JvmStream { + pub fn new(data: Vec) -> Self { + JvmStream { + data, + pos: 0, + handles: Vec::new(), + class_descs: Vec::new(), + } + } + + pub fn read_byte(&mut self) -> std::io::Result { + if self.pos >= self.data.len() { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "unexpected end of stream", + )); + } + let b = self.data[self.pos]; + self.pos += 1; + Ok(b) + } + + pub fn read_bytes(&mut self, n: usize) -> std::io::Result> { + if self.pos + n > self.data.len() { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "unexpected end of stream", + )); + } + let slice = self.data[self.pos..self.pos + n].to_vec(); + self.pos += n; + Ok(slice) + } + + pub fn peek_byte(&self) -> std::io::Result { + if self.pos >= self.data.len() { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "unexpected end of stream", + )); + } + Ok(self.data[self.pos]) + } + + pub fn read_u16(&mut self) -> std::io::Result { + let hi = self.read_byte()? as u16; + let lo = self.read_byte()? as u16; + Ok((hi << 8) | lo) + } + + pub fn read_i32(&mut self) -> std::io::Result { + let b1 = self.read_byte()? as i32; + let b2 = self.read_byte()? as i32; + let b3 = self.read_byte()? as i32; + let b4 = self.read_byte()? as i32; + Ok((b1 << 24) | (b2 << 16) | (b3 << 8) | b4) + } + + pub fn read_i64(&mut self) -> std::io::Result { + let b1 = self.read_byte()? as i64; + let b2 = self.read_byte()? as i64; + let b3 = self.read_byte()? as i64; + let b4 = self.read_byte()? as i64; + let b5 = self.read_byte()? as i64; + let b6 = self.read_byte()? as i64; + let b7 = self.read_byte()? as i64; + let b8 = self.read_byte()? as i64; + Ok((b1 << 56) + | (b2 << 48) + | (b3 << 40) + | (b4 << 32) + | (b5 << 24) + | (b6 << 16) + | (b7 << 8) + | b8) + } + + pub fn read_f32(&mut self) -> std::io::Result { + Ok(f32::from_bits(self.read_i32()? as u32)) + } + + pub fn read_f64(&mut self) -> std::io::Result { + Ok(f64::from_bits(self.read_i64()? as u64)) + } + + pub fn read_utf(&mut self) -> std::io::Result { + let len = self.read_u16()? as usize; + let bytes = self.read_bytes(len)?; + Ok(String::from_utf8_lossy(&bytes).to_string()) + } + + pub fn read_long_utf(&mut self) -> std::io::Result { + let len = self.read_i64()? as usize; + let bytes = self.read_bytes(len)?; + Ok(String::from_utf8_lossy(&bytes).to_string()) + } + + pub fn read_content(&mut self) -> std::io::Result { + loop { + if self.pos >= self.data.len() { + return Ok(JvmValue::Null); + } + let tc = self.read_byte()?; + match tc { + TC_OBJECT => return self.read_object(), + TC_ARRAY => return self.read_array(), + TC_STRING => { + let s = self.read_utf()?; + self.push_handle(JvmValue::String(s.clone())); + return Ok(JvmValue::String(s)); + } + TC_LONGSTRING => { + let s = self.read_long_utf()?; + self.push_handle(JvmValue::String(s.clone())); + return Ok(JvmValue::String(s)); + } + TC_REFERENCE => { + let handle = self.read_u32_handle(); + let idx = (handle - BASE_WIRE_HANDLE) as usize; + if idx < self.handles.len() { + return Ok(self.handles[idx].clone()); + } + return Ok(JvmValue::Null); + } + TC_NULL => return Ok(JvmValue::Null), + TC_CLASS => { + let _desc = self.read_classdesc()?; + return Ok(JvmValue::Skipped); + } + TC_ENUM => { + let _desc = self.read_classdesc()?; + let _ordinal = self.read_i32()?; + return Ok(JvmValue::Skipped); + } + TC_BLOCKDATA => { + let len = self.read_byte()? as usize; + self.pos += len; + } + TC_BLOCKDATALONG => { + let len = self.read_i32()? as usize; + self.pos += len; + } + TC_RESET => { + self.handles.clear(); + } + _ => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("unexpected content token: 0x{:02x}", tc), + )); + } + } + } + } + + pub fn push_handle(&mut self, val: JvmValue) -> u32 { + let idx = self.handles.len(); + self.handles.push(val); + BASE_WIRE_HANDLE + idx as u32 + } + + pub fn read_u32_handle(&mut self) -> u32 { + let mut buf = [0u8; 4]; + buf.copy_from_slice(&self.data[self.pos..self.pos + 4]); + self.pos += 4; + u32::from_be_bytes(buf) + } +} diff --git a/core/src/io/wp_import/mod.rs b/core/src/io/wp_import/mod.rs new file mode 100644 index 0000000..49d4a18 --- /dev/null +++ b/core/src/io/wp_import/mod.rs @@ -0,0 +1,169 @@ +//! WorldPainter .world file import. +//! +//! Reads WorldPainter `.world` files, which use Java serialization format. +//! Implements a minimal Java ObjectInputStream parser sufficient for extracting +//! tile heightmap, terrain, and water level data from WorldPainter worlds. + +mod extract; +mod java_reader; +mod types; + +use std::io::Read; +use std::path::{Path, PathBuf}; + +pub use extract::*; +pub use types::WpImportError; + +use crate::model::dimension::Dimension; +use crate::model::types::Platform; +use crate::model::tile::Tile; +use crate::model::world::World; + +use java_reader::JvmStream; +use types::Result; + +pub struct WpImporter { + path: PathBuf, +} + +impl WpImporter { + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } + } + + pub fn import(&self) -> Result { + import_world_file(&self.path) + } +} + +pub fn is_world_file(path: &Path) -> bool { + path.extension().is_some_and(|ext| ext == "world") + || path + .file_name() + .is_some_and(|name| name.to_string_lossy().ends_with(".world")) +} + +pub fn import_world_file(path: &Path) -> Result { + let raw = std::fs::read(path).map_err(WpImportError::Io)?; + + if raw.is_empty() { + return Err(WpImportError::InvalidFormat("file is empty".into())); + } + + let data = if raw.len() >= 2 && raw[0] == 0x1F && raw[1] == 0x8B { + let mut decoder = flate2::read::GzDecoder::new(&raw[..]); + let mut decompressed = Vec::new(); + decoder + .read_to_end(&mut decompressed) + .map_err(|e| WpImportError::Compression(e.to_string()))?; + decompressed + } else { + raw + }; + + if data.len() < 4 { + return Err(WpImportError::InvalidFormat( + "file too small after decompression".into(), + )); + } + let magic = u16::from_be_bytes([data[0], data[1]]); + if magic != 0xACED { + return Err(WpImportError::InvalidFormat(format!( + "not a Java serialization stream (expected magic ACED, got {:04X})", + magic + ))); + } + let stream_version = u16::from_be_bytes([data[2], data[3]]); + if stream_version != 5 { + return Err(WpImportError::InvalidFormat(format!( + "unsupported Java serialization stream version: {}", + stream_version + ))); + } + + let mut stream = JvmStream::new(data); + + let root = stream + .read_content() + .map_err(|e| WpImportError::InvalidFormat(e.to_string()))?; + + let world_data = extract::extract_world_data(&root)?; + + if world_data.tiles.is_empty() && !world_data.name.is_empty() { + log::warn!("WorldPainter world '{}' has no tiles", world_data.name); + } + + let platform = Platform::java_1_18(); + + let mut tiles = std::collections::HashMap::new(); + for wp_tile in &world_data.tiles { + let mut tile = Tile::new( + wp_tile.x, + wp_tile.z, + platform.min_height, + platform.max_height, + ); + + if let Some(hm) = &wp_tile.heightmap { + tile.heightmap = *hm; + } + if let Some(ter) = &wp_tile.terrain { + tile.terrain = *ter; + } + if let Some(wl) = &wp_tile.water_level { + tile.water_level = *wl; + } + + tiles.insert((wp_tile.x, wp_tile.z), tile); + } + + let dimension = Dimension { + name: "overworld".to_string(), + tiles, + min_height: platform.min_height, + max_height: platform.max_height, + seed: world_data.seed, + }; + + Ok(World { + name: world_data.name, + platform, + dimensions: vec![dimension], + seed: world_data.seed, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn test_no_file() { + let result = import_world_file(Path::new("/nonexistent/magic.world")); + assert!(result.is_err()); + } + + #[test] + fn test_invalid_format() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.world"); + let mut f = std::fs::File::create(&path).unwrap(); + f.write_all(b"not a world file").unwrap(); + let result = import_world_file(&path); + assert!(result.is_err()); + } + + #[test] + fn test_not_world() { + let result = import_world_file(Path::new("/tmp")); + assert!(result.is_err()); + } + + #[test] + fn test_is_world_file() { + assert!(is_world_file(Path::new("test.world"))); + assert!(!is_world_file(Path::new("test.mca"))); + assert!(!is_world_file(Path::new("level.dat"))); + } +} diff --git a/core/src/io/wp_import/types/java_classdesc.rs b/core/src/io/wp_import/types/java_classdesc.rs new file mode 100644 index 0000000..42b1ee2 --- /dev/null +++ b/core/src/io/wp_import/types/java_classdesc.rs @@ -0,0 +1,186 @@ +use std::collections::HashMap; + +use super::java_types::{ + ClassDesc, FieldInfo, JvmValue, BASE_WIRE_HANDLE, TC_ARRAY, TC_BLOCKDATA, TC_BLOCKDATALONG, + TC_CLASS, TC_CLASSDESC, TC_ENDBLOCKDATA, TC_ENUM, TC_LONGSTRING, TC_NULL, TC_OBJECT, + TC_PROXYCLASSDESC, TC_REFERENCE, TC_RESET, TC_STRING, +}; +use super::super::java_reader::JvmStream; + +impl JvmStream { + pub fn read_classdesc(&mut self) -> std::io::Result { + let tc = self.peek_byte()?; + match tc { + TC_CLASSDESC => self.read_classdesc_body(), + TC_REFERENCE => { + self.read_byte()?; + let handle = self.read_u32_handle(); + let idx = (handle - BASE_WIRE_HANDLE) as usize; + if idx < self.handles.len() { + Ok(self.handles[idx].clone()) + } else { + Ok(JvmValue::Null) + } + } + TC_NULL => { + self.read_byte()?; + Ok(JvmValue::Null) + } + TC_PROXYCLASSDESC => { + self.read_byte()?; + let _iface_count = self.read_i32()?; + for _ in 0.._iface_count { + let _iface = self.read_utf()?; + } + self.read_class_annotations()?; + let _super = self.read_classdesc()?; + self.push_handle(JvmValue::Skipped); + Ok(JvmValue::Skipped) + } + _ => Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("expected classdesc token, got 0x{:02x}", tc), + )), + } + } + + pub fn read_classdesc_body(&mut self) -> std::io::Result { + self.read_byte()?; + let class_name = self.read_utf()?; + let _serial_version_uid = self.read_i64()?; + let desc_flags = self.read_byte()?; + let field_count = self.read_u16()?; + + let mut fields = Vec::new(); + for _ in 0..field_count { + let type_code = self.read_byte()?; + let name = self.read_utf()?; + if type_code == b'[' || type_code == b'L' { + let tc = self.read_byte()?; + match tc { + TC_STRING => { + self.read_utf()?; + } + TC_REFERENCE => { + let handle = self.read_u32_handle(); + let idx = (handle - BASE_WIRE_HANDLE) as usize; + if idx < self.handles.len() { + let _ = self.handles[idx].as_string(); + } + } + TC_LONGSTRING => { + self.read_long_utf()?; + } + _ => {} + } + } + fields.push(FieldInfo { name, type_code }); + } + + let cd = ClassDesc { + name: class_name.clone(), + flags: desc_flags, + fields, + }; + self.class_descs.push(cd.clone()); + self.push_handle(JvmValue::ClassDesc(cd.clone())); + self.read_class_annotations()?; + let _super = self.read_classdesc()?; + + Ok(JvmValue::ClassDesc(cd)) + } + + pub fn read_class_annotations(&mut self) -> std::io::Result<()> { + loop { + let tc = self.read_byte()?; + match tc { + TC_ENDBLOCKDATA => return Ok(()), + TC_BLOCKDATA => { + let len = self.read_byte()? as usize; + self.pos += len; + } + TC_BLOCKDATALONG => { + let len = self.read_i32()? as usize; + self.pos += len; + } + TC_OBJECT | TC_ARRAY | TC_STRING | TC_LONGSTRING | TC_ENUM => { + self.pos -= 1; + let _val = self.read_content()?; + } + TC_REFERENCE => { + let _handle = self.read_u32_handle(); + } + TC_CLASSDESC | TC_PROXYCLASSDESC => { + self.pos -= 1; + let _desc = self.read_classdesc()?; + } + TC_NULL => {} + TC_CLASS => { + let _desc = self.read_classdesc()?; + } + TC_RESET => { + self.handles.clear(); + } + other => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "unexpected token in class annotations: 0x{:02x}", + other + ), + )); + } + } + } + } + + pub fn read_object(&mut self) -> std::io::Result { + let class_desc_val = self.read_classdesc()?; + let cd = match &class_desc_val { + JvmValue::ClassDesc(cd) => cd.clone(), + JvmValue::Null => { + return Ok(JvmValue::Object(HashMap::new())); + } + _ => { + let name = class_desc_val.as_string().unwrap_or_default(); + let cd = self.resolve_class_desc(&name); + match cd { + Some(cd) => cd, + None => { + let _handle = self.push_handle(JvmValue::Skipped); + self.skip_object_data(&name)?; + return Ok(JvmValue::Skipped); + } + } + } + }; + + let handle = self.push_handle(JvmValue::Skipped); + + let obj = match cd.name.as_str() { + "java.util.HashMap" | "java.util.LinkedHashMap" => self.read_hashmap_data()?, + "java.util.HashSet" | "java.util.LinkedHashSet" => self.read_hashset_data()?, + "java.util.ArrayList" => self.read_arraylist_data()?, + "java.lang.Integer" => { + let val = self.read_i32()?; + self.handles[(handle - BASE_WIRE_HANDLE) as usize] = JvmValue::Int(val); + return Ok(JvmValue::Int(val)); + } + "java.lang.Long" => { + let val = self.read_i64()?; + self.handles[(handle - BASE_WIRE_HANDLE) as usize] = JvmValue::Long(val); + return Ok(JvmValue::Long(val)); + } + "java.lang.String" => { + let val = self.read_content()?; + self.handles[(handle - BASE_WIRE_HANDLE) as usize] = val.clone(); + return Ok(val); + } + _ => self.read_object_fields(&cd)?, + }; + + self.handles[(handle - BASE_WIRE_HANDLE) as usize] = obj.clone(); + Ok(obj) + } + +} diff --git a/core/src/io/wp_import/types/java_fields.rs b/core/src/io/wp_import/types/java_fields.rs new file mode 100644 index 0000000..1bf96c4 --- /dev/null +++ b/core/src/io/wp_import/types/java_fields.rs @@ -0,0 +1,192 @@ +use std::collections::HashMap; + +use super::java_types::{ + ClassDesc, FieldInfo, JvmValue, PRIM_BOOL, PRIM_BYTE, PRIM_CHAR, PRIM_DOUBLE, PRIM_FLOAT, + PRIM_INT, PRIM_LONG, PRIM_SHORT, SC_WRITE_METHOD, TC_BLOCKDATA, TC_BLOCKDATALONG, +}; +use super::super::java_reader::JvmStream; + +impl JvmStream { + pub fn read_object_fields(&mut self, cd: &ClassDesc) -> std::io::Result { + let mut map = HashMap::new(); + + for field in &cd.fields { + let val = self.read_object_field(field)?; + map.insert(field.name.clone(), val); + } + + if (cd.flags & SC_WRITE_METHOD) != 0 { + self.read_class_annotations()?; + } + + let super_name = self.find_super_class_name(&cd.name); + if let Some(super_name) = super_name + && !super_name.is_empty() + && super_name != "java.lang.Object" + && let Some(super_cd) = self.resolve_class_desc(&super_name) + { + let super_fields = self.read_object_fields(&super_cd)?; + if let JvmValue::Object(super_map) = super_fields { + map.extend(super_map); + } + } + + Ok(JvmValue::Object(map)) + } + + pub fn find_super_class_name(&self, _class_name: &str) -> Option { + None + } + + pub fn read_object_field(&mut self, field: &FieldInfo) -> std::io::Result { + match field.type_code { + PRIM_BYTE => Ok(JvmValue::Int(self.read_byte()? as i32)), + PRIM_SHORT => Ok(JvmValue::Int(self.read_u16()? as i16 as i32)), + PRIM_INT => Ok(JvmValue::Int(self.read_i32()?)), + PRIM_LONG => Ok(JvmValue::Long(self.read_i64()?)), + PRIM_FLOAT => { + let v = self.read_f32()?; + Ok(JvmValue::Int(v as i32)) + } + PRIM_DOUBLE => { + let v = self.read_f64()?; + Ok(JvmValue::Long(v as i64)) + } + PRIM_BOOL => Ok(JvmValue::Int(self.read_byte()? as i32)), + PRIM_CHAR => Ok(JvmValue::Int(self.read_u16()? as i32)), + b'L' | b'[' => self.read_content(), + _ => self.read_content(), + } + } + + pub fn read_array(&mut self) -> std::io::Result { + let class_desc_val = self.read_classdesc()?; + let class_name = class_desc_val.as_string().unwrap_or_default(); + let length = self.read_i32()? as usize; + + if length == 0 { + self.push_handle(JvmValue::Skipped); + return Ok(JvmValue::Skipped); + } + + let elem_type = class_name.as_bytes().get(1).copied(); + + let result = match elem_type { + Some(PRIM_BYTE) => { + let bytes = self.read_bytes(length)?; + let arr: Vec = bytes.iter().map(|&b| b as i8).collect(); + JvmValue::ByteArray(arr) + } + Some(PRIM_SHORT) => { + let mut arr = Vec::with_capacity(length); + for _ in 0..length { + arr.push(self.read_u16()? as i16); + } + JvmValue::ShortArray(arr) + } + Some(PRIM_INT) => { + let mut arr = Vec::with_capacity(length); + for _ in 0..length { + arr.push(self.read_i32()?); + } + JvmValue::IntArray(arr) + } + Some(PRIM_LONG) => { + for _ in 0..length { + let _ = self.read_i64()?; + } + JvmValue::LongArray(()) + } + _ => { + for _ in 0..length { + let _elem = self.read_content()?; + } + JvmValue::Skipped + } + }; + + self.push_handle(result.clone()); + Ok(result) + } + + pub fn read_hashmap_data(&mut self) -> std::io::Result { + let _capacity = self.read_i32()?; + let _load_factor = self.read_i32()?; + let _threshold = self.read_i32()?; + let size = self.read_i32()?; + + let mut map = HashMap::new(); + for _ in 0..size { + let key = self.read_content()?; + let value = self.read_content()?; + let key_str = key + .as_string() + .unwrap_or_else(|| format!("__key_{}", map.len())); + map.insert(key_str, value); + } + + Ok(JvmValue::Object(map)) + } + + pub fn read_hashset_data(&mut self) -> std::io::Result { + let _capacity = self.read_i32()?; + let _load_factor = self.read_i32()?; + let _threshold = self.read_i32()?; + let size = self.read_i32()?; + + let mut map = HashMap::new(); + for i in 0..size { + let val = self.read_content()?; + let key = val.as_string().unwrap_or_else(|| format!("__elem_{}", i)); + map.insert(key, val); + } + + Ok(JvmValue::Object(map)) + } + + pub fn read_arraylist_data(&mut self) -> std::io::Result { + let size = self.read_i32()?; + + let mut map = HashMap::new(); + for i in 0..size { + let val = self.read_content()?; + let key = val.as_string().unwrap_or_else(|| format!("__elem_{}", i)); + map.insert(key, val); + } + + Ok(JvmValue::Object(map)) + } + + pub fn resolve_class_desc(&self, name: &str) -> Option { + for cd in &self.class_descs { + if cd.name == name { + return Some(cd.clone()); + } + } + None + } + + pub fn skip_object_data(&mut self, _class_name: &str) -> std::io::Result<()> { + let saved = self.pos; + match self.read_class_annotations() { + Ok(()) => Ok(()), + Err(_) => { + self.pos = saved; + if self.pos < self.data.len() { + let tc = match self.peek_byte() { + Ok(t) => t, + Err(_) => return Ok(()), + }; + if tc == TC_BLOCKDATA || tc == TC_BLOCKDATALONG { + return self.read_class_annotations(); + } + let _remaining = self.data.len() - self.pos; + self.pos = self.data.len(); + Ok(()) + } else { + Ok(()) + } + } + } + } +} diff --git a/core/src/io/wp_import/types/java_types.rs b/core/src/io/wp_import/types/java_types.rs new file mode 100644 index 0000000..29fd793 --- /dev/null +++ b/core/src/io/wp_import/types/java_types.rs @@ -0,0 +1,112 @@ +use std::collections::HashMap; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum WpImportError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("Invalid WorldPainter file: {0}")] + InvalidFormat(String), + #[error("Unsupported WorldPainter version: {0}")] + UnsupportedVersion(i32), + #[error("Compression error: {0}")] + Compression(String), + #[error("Missing data: {0}")] + MissingData(String), +} + +pub type Result = std::result::Result; + +pub const TC_NULL: u8 = 0x70; +pub const TC_REFERENCE: u8 = 0x71; +pub const TC_CLASSDESC: u8 = 0x72; +pub const TC_OBJECT: u8 = 0x73; +pub const TC_STRING: u8 = 0x74; +pub const TC_ARRAY: u8 = 0x75; +pub const TC_CLASS: u8 = 0x76; +pub const TC_BLOCKDATA: u8 = 0x77; +pub const TC_ENDBLOCKDATA: u8 = 0x78; +pub const TC_RESET: u8 = 0x79; +pub const TC_BLOCKDATALONG: u8 = 0x7A; +pub const TC_LONGSTRING: u8 = 0x7C; +pub const TC_PROXYCLASSDESC: u8 = 0x7D; +pub const TC_ENUM: u8 = 0x7E; +pub const SC_WRITE_METHOD: u8 = 0x01; +pub const BASE_WIRE_HANDLE: u32 = 0x7E0000; +pub const PRIM_BYTE: u8 = b'B'; +pub const PRIM_CHAR: u8 = b'C'; +pub const PRIM_DOUBLE: u8 = b'D'; +pub const PRIM_FLOAT: u8 = b'F'; +pub const PRIM_INT: u8 = b'I'; +pub const PRIM_LONG: u8 = b'J'; +pub const PRIM_SHORT: u8 = b'S'; +pub const PRIM_BOOL: u8 = b'Z'; + +#[derive(Debug, Clone)] +pub struct FieldInfo { + pub name: String, + pub type_code: u8, +} + +#[derive(Debug, Clone)] +pub struct ClassDesc { + pub name: String, + pub flags: u8, + pub fields: Vec, +} + +#[derive(Debug, Clone)] +pub enum JvmValue { + Null, + String(String), + ClassDesc(ClassDesc), + Object(HashMap), + ByteArray(Vec), + ShortArray(Vec), + IntArray(Vec), + LongArray(()), + Int(i32), + Long(i64), + Skipped, +} + +impl JvmValue { + pub fn as_string(&self) -> Option { + match self { + JvmValue::String(s) => Some(s.clone()), + JvmValue::Object(map) => { + for key in &["name", "key", "id", "value"] { + if let Some(val) = map.get(*key) + && let JvmValue::String(s) = val + { + return Some(s.clone()); + } + } + None + } + _ => None, + } + } + + pub fn as_i32(&self) -> Option { + match self { + JvmValue::Int(v) => Some(*v), + _ => None, + } + } + + pub fn as_u64(&self) -> Option { + match self { + JvmValue::Long(v) => Some(*v as u64), + JvmValue::Int(v) => Some(*v as u64), + _ => None, + } + } + + pub fn as_object(&self) -> Option<&HashMap> { + match self { + JvmValue::Object(m) => Some(m), + _ => None, + } + } +} diff --git a/core/src/io/wp_import/types/mod.rs b/core/src/io/wp_import/types/mod.rs new file mode 100644 index 0000000..f892086 --- /dev/null +++ b/core/src/io/wp_import/types/mod.rs @@ -0,0 +1,5 @@ +mod java_classdesc; +mod java_fields; +mod java_types; + +pub use java_types::*; diff --git a/core/src/lib.rs b/core/src/lib.rs index ff0c3f5..f1be27c 100755 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -14,7 +14,8 @@ pub use coords::{BlockCoords, ChunkCoords, RegionCoords, TileCoords}; pub use io::export; pub use io::import; pub use model::dimension::Dimension; -pub use model::platform::Platform; -pub use model::terrain::Terrain; +pub use model::types::{Platform, Terrain}; pub use model::tile::Tile; pub use model::world::World; + +pub use terrafier_biome_db as biome_db; diff --git a/core/src/model/brush.rs b/core/src/model/brush.rs deleted file mode 100755 index 2ee2f8d..0000000 --- a/core/src/model/brush.rs +++ /dev/null @@ -1,28 +0,0 @@ -pub trait Brush: Send + Sync { - fn get_strength(&self, dx: f64, dy: f64) -> f64; - fn radius(&self) -> f64; -} - -pub struct SymmetricBrush { - pub radius: f64, -} - -impl SymmetricBrush { - pub fn new(radius: f64) -> Self { - Self { radius } - } -} - -impl Brush for SymmetricBrush { - fn get_strength(&self, dx: f64, dy: f64) -> f64 { - let dist = (dx * dx + dy * dy).sqrt(); - if dist >= self.radius { - 0.0 - } else { - 1.0 - (dist / self.radius) - } - } - fn radius(&self) -> f64 { - self.radius - } -} diff --git a/core/src/model/layers/caves.rs b/core/src/model/layers/caves.rs new file mode 100644 index 0000000..cd21645 --- /dev/null +++ b/core/src/model/layers/caves.rs @@ -0,0 +1,49 @@ +use super::{DataSize, Layer}; + +pub struct CavesLayer; +impl Layer for CavesLayer { + fn id(&self) -> &'static str { + "caves" + } + fn name(&self) -> &'static str { + "Caves" + } + fn data_size(&self) -> DataSize { + DataSize::Nibble + } + fn priority(&self) -> i32 { + 10 + } +} + +pub struct RiverLayer; +impl Layer for RiverLayer { + fn id(&self) -> &'static str { + "river" + } + fn name(&self) -> &'static str { + "River" + } + fn data_size(&self) -> DataSize { + DataSize::Byte + } + fn priority(&self) -> i32 { + 20 + } +} + +pub struct FrostLayer; +impl Layer for FrostLayer { + fn id(&self) -> &'static str { + "frost" + } + fn name(&self) -> &'static str { + "Frost" + } + fn data_size(&self) -> DataSize { + DataSize::Bit + } + fn priority(&self) -> i32 { + 30 + } +} diff --git a/core/src/model/layers/mod.rs b/core/src/model/layers/mod.rs index 954885d..ef173ec 100644 --- a/core/src/model/layers/mod.rs +++ b/core/src/model/layers/mod.rs @@ -1,6 +1,10 @@ //! Layer system — defines the Layer trait and built-in layer types. -pub mod builtin; +pub mod caves; +pub mod surface; + +pub use caves::{CavesLayer, FrostLayer, RiverLayer}; +pub use surface::{BiomeLayer, ResourcesLayer, TreesLayer}; pub trait Layer: Send + Sync { fn id(&self) -> &'static str; diff --git a/core/src/model/layers/builtin.rs b/core/src/model/layers/surface.rs similarity index 51% rename from core/src/model/layers/builtin.rs rename to core/src/model/layers/surface.rs index 4c4aa44..f8bb6aa 100644 --- a/core/src/model/layers/builtin.rs +++ b/core/src/model/layers/surface.rs @@ -1,53 +1,5 @@ use super::{DataSize, Layer}; -pub struct CavesLayer; -impl Layer for CavesLayer { - fn id(&self) -> &'static str { - "caves" - } - fn name(&self) -> &'static str { - "Caves" - } - fn data_size(&self) -> DataSize { - DataSize::Nibble - } - fn priority(&self) -> i32 { - 10 - } -} - -pub struct RiverLayer; -impl Layer for RiverLayer { - fn id(&self) -> &'static str { - "river" - } - fn name(&self) -> &'static str { - "River" - } - fn data_size(&self) -> DataSize { - DataSize::Byte - } - fn priority(&self) -> i32 { - 20 - } -} - -pub struct FrostLayer; -impl Layer for FrostLayer { - fn id(&self) -> &'static str { - "frost" - } - fn name(&self) -> &'static str { - "Frost" - } - fn data_size(&self) -> DataSize { - DataSize::Bit - } - fn priority(&self) -> i32 { - 30 - } -} - pub struct TreesLayer; impl Layer for TreesLayer { fn id(&self) -> &'static str { diff --git a/core/src/model/mod.rs b/core/src/model/mod.rs index ce91f0f..710b7a7 100755 --- a/core/src/model/mod.rs +++ b/core/src/model/mod.rs @@ -2,16 +2,11 @@ //! //! Contains World, Dimension, Tile, Terrain types and related utilities. -pub mod brush; -pub mod dimension; pub mod layers; -pub mod platform; -pub mod terrain; pub mod tile; +pub mod types; pub mod world; -pub use dimension::Dimension; -pub use platform::Platform; -pub use terrain::Terrain; pub use tile::Tile; -pub use world::World; +pub use types::{Brush, Platform, SymmetricBrush, Terrain}; +pub use world::*; diff --git a/core/src/model/platform.rs b/core/src/model/platform.rs deleted file mode 100755 index 55cfc23..0000000 --- a/core/src/model/platform.rs +++ /dev/null @@ -1,20 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Platform { - pub id: String, - pub display_name: String, - pub min_height: i16, - pub max_height: i16, -} - -impl Platform { - pub fn java_1_18() -> Self { - Self { - id: "java_anvil_1_18".to_string(), - display_name: "Minecraft Java 1.18+".to_string(), - min_height: -64, - max_height: 320, - } - } -} diff --git a/core/src/model/terrain.rs b/core/src/model/terrain.rs deleted file mode 100755 index 35819c6..0000000 --- a/core/src/model/terrain.rs +++ /dev/null @@ -1,26 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum Terrain { - Desert, - Grass, - Forest, - Rock, - Sand, - Swamp, - Water, -} - -impl Terrain { - pub fn name(&self) -> &'static str { - match self { - Terrain::Desert => "Desert", - Terrain::Grass => "Grass", - Terrain::Forest => "Forest", - Terrain::Rock => "Rock", - Terrain::Sand => "Sand", - Terrain::Swamp => "Swamp", - Terrain::Water => "Water", - } - } -} diff --git a/core/src/model/types.rs b/core/src/model/types.rs new file mode 100644 index 0000000..cec96cf --- /dev/null +++ b/core/src/model/types.rs @@ -0,0 +1,74 @@ +use serde::{Deserialize, Serialize}; + +pub trait Brush: Send + Sync { + fn get_strength(&self, dx: f64, dy: f64) -> f64; + fn radius(&self) -> f64; +} + +pub struct SymmetricBrush { + pub radius: f64, +} + +impl SymmetricBrush { + pub fn new(radius: f64) -> Self { + Self { radius } + } +} + +impl Brush for SymmetricBrush { + fn get_strength(&self, dx: f64, dy: f64) -> f64 { + let dist = (dx * dx + dy * dy).sqrt(); + if dist >= self.radius { + 0.0 + } else { + 1.0 - (dist / self.radius) + } + } + fn radius(&self) -> f64 { + self.radius + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Platform { + pub id: String, + pub display_name: String, + pub min_height: i16, + pub max_height: i16, +} + +impl Platform { + pub fn java_1_18() -> Self { + Self { + id: "java_anvil_1_18".to_string(), + display_name: "Minecraft Java 1.18+".to_string(), + min_height: -64, + max_height: 320, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Terrain { + Desert, + Grass, + Forest, + Rock, + Sand, + Swamp, + Water, +} + +impl Terrain { + pub fn name(&self) -> &'static str { + match self { + Terrain::Desert => "Desert", + Terrain::Grass => "Grass", + Terrain::Forest => "Forest", + Terrain::Rock => "Rock", + Terrain::Sand => "Sand", + Terrain::Swamp => "Swamp", + Terrain::Water => "Water", + } + } +} diff --git a/core/src/model/dimension.rs b/core/src/model/world/dimension.rs similarity index 100% rename from core/src/model/dimension.rs rename to core/src/model/world/dimension.rs diff --git a/core/src/model/world/mod.rs b/core/src/model/world/mod.rs new file mode 100644 index 0000000..e4ed8a1 --- /dev/null +++ b/core/src/model/world/mod.rs @@ -0,0 +1,5 @@ +pub mod dimension; +pub mod world; + +pub use dimension::*; +pub use world::*; diff --git a/core/src/model/world.rs b/core/src/model/world/world.rs similarity index 79% rename from core/src/model/world.rs rename to core/src/model/world/world.rs index e3b6c7e..2d5dba4 100755 --- a/core/src/model/world.rs +++ b/core/src/model/world/world.rs @@ -1,8 +1,8 @@ use rayon::prelude::*; use serde::{Deserialize, Serialize}; -use crate::model::dimension::Dimension; -use crate::model::platform::Platform; +use super::dimension::Dimension; +use crate::model::types::Platform; use crate::ops::heightmap::{HeightMapSource, NoiseHeightMap}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -14,11 +14,15 @@ pub struct World { } impl World { - /// Create a new world with a single overworld dimension and generated terrain. + /// Create a new world with a single overworld dimension and noise-generated terrain. pub fn new(name: &str, seed: u64) -> Self { + Self::with_heightmap(name, seed, NoiseHeightMap::default()) + } + + /// Create a new world using a custom height map source. + pub fn with_heightmap(name: &str, seed: u64, heightmap: impl HeightMapSource + 'static) -> Self { let platform = Platform::java_1_18(); - // Generate tiles in parallel using rayon let coords: Vec<(i32, i32)> = (-1..=1) .flat_map(|tx| (-1..=1).map(move |tz| (tx, tz))) .collect(); @@ -28,8 +32,7 @@ impl World { .map(|&(tx, tz)| { let mut tile = crate::model::tile::Tile::new(tx, tz, platform.min_height, platform.max_height); - let source = NoiseHeightMap::default(); - source.generate( + heightmap.generate( &mut tile, seed.wrapping_add( (tx.wrapping_mul(374_761_393) as u64) @@ -41,7 +44,8 @@ impl World { .collect(); let mut dim_tiles = std::collections::HashMap::with_capacity(tiles.len()); - for (tx, tz, tile) in tiles { + for (tx, tz, mut tile) in tiles { + crate::ops::layers::generate_all_layers(&mut tile, seed); dim_tiles.insert((tx, tz), tile); } diff --git a/core/src/ops/filters.rs b/core/src/ops/filters.rs index 7d762c4..b3e9d69 100644 --- a/core/src/ops/filters.rs +++ b/core/src/ops/filters.rs @@ -1,44 +1,35 @@ //! Filter system — restrict which cells an operation affects based on terrain, height, or slope. - use std::sync::OnceLock; - use crate::model::dimension::Dimension; -use crate::model::terrain::Terrain; -use crate::model::tile::{TILE_SIZE, Tile}; +use crate::model::types::Terrain; +use crate::model::tile::{Tile, TILE_SIZE}; use crate::ops::operations::{Operation, OperationError, RestoreHeightsOperation}; - /// Determines whether a cell should be modified by an operation. pub trait Filter: Send + Sync { fn name(&self) -> &'static str; fn should_apply(&self, tile: &Tile, local_x: usize, local_z: usize) -> bool; } - /// Only apply where terrain matches a specific type. pub struct TerrainFilter { pub terrain: Terrain, } - impl Filter for TerrainFilter { fn name(&self) -> &'static str { "TerrainFilter" } - fn should_apply(&self, tile: &Tile, local_x: usize, local_z: usize) -> bool { tile.get_terrain(local_x, local_z) == self.terrain as u8 } } - /// Only apply where height is within a range. pub struct HeightFilter { pub min: i16, pub max: i16, } - impl Filter for HeightFilter { fn name(&self) -> &'static str { "HeightFilter" } - fn should_apply(&self, tile: &Tile, local_x: usize, local_z: usize) -> bool { let h = tile.get_height(local_x, local_z); h >= self.min && h <= self.max @@ -55,7 +46,6 @@ impl Filter for SlopeFilter { fn name(&self) -> &'static str { "SlopeFilter" } - fn should_apply(&self, tile: &Tile, local_x: usize, local_z: usize) -> bool { let center = tile.get_height(local_x, local_z); let mut max_diff = 0i16; @@ -91,7 +81,6 @@ impl Filter for CompositeFilter { fn name(&self) -> &'static str { "CompositeFilter" } - fn should_apply(&self, tile: &Tile, local_x: usize, local_z: usize) -> bool { match self.combinator { FilterCombinator::And => self @@ -115,7 +104,6 @@ impl Filter for InvertFilter { fn name(&self) -> &'static str { "InvertFilter" } - fn should_apply(&self, tile: &Tile, local_x: usize, local_z: usize) -> bool { !self.filter.should_apply(tile, local_x, local_z) } @@ -123,11 +111,7 @@ impl Filter for InvertFilter { /// Wraps any operation and restricts its effect to cells that pass the filter. /// -/// On apply: -/// 1. Snapshots all cell heights in the brush area. -/// 2. Runs the inner operation. -/// 3. Restores heights of cells that do NOT pass the filter. -/// +/// On apply: snapshots brush area heights, runs inner op, restores filtered-out cells. /// Inverse restores all cells from the snapshot. pub struct FilteredOperation { pub operation: Box, @@ -148,7 +132,6 @@ impl Operation for FilteredOperation { fn apply(&self, dim: &mut Dimension) -> Result<(), OperationError> { let is_first_apply = self.before_snapshot.get().is_none(); - // Phase 1: snapshot all cells in brush area (if first apply) let snapshot: Vec<(usize, i16)> = if is_first_apply { let tile = dim.tiles @@ -182,10 +165,8 @@ impl Operation for FilteredOperation { self.before_snapshot.get().cloned().unwrap_or_default() }; - // Phase 2: apply inner operation self.operation.apply(dim)?; - // Phase 3: restore cells that don't pass the filter let tile = dim.tiles .get_mut(&(self.tile_x, self.tile_z)) diff --git a/core/src/ops/heightmap.rs b/core/src/ops/heightmap.rs index 9f5be0f..0026e5a 100755 --- a/core/src/ops/heightmap.rs +++ b/core/src/ops/heightmap.rs @@ -1,6 +1,6 @@ //! Height map sources — generate height maps from noise, flat, or combined sources. -use crate::model::terrain::Terrain; +use crate::model::types::Terrain; use crate::model::tile::Tile; /// A source that can generate a height map for a tile. diff --git a/core/src/ops/layers/mod.rs b/core/src/ops/layers/mod.rs new file mode 100644 index 0000000..b34bf1e --- /dev/null +++ b/core/src/ops/layers/mod.rs @@ -0,0 +1,183 @@ +use crate::model::layers::{DataSize, LAYER_BIOME}; +use crate::model::tile::Tile; + +mod noise; +mod terrain; + +pub use noise::{CavesGenerator, FrostGenerator, RiverGenerator}; +pub use terrain::{BiomeGenerator, ResourcesGenerator, TreesGenerator}; + +pub trait LayerGenerator: Send + Sync { + fn layer_id(&self) -> u32; + fn generate(&self, tile: &mut Tile, seed: u64); +} + +pub fn generate_all_layers(tile: &mut Tile, seed: u64) { + let biome_data = terrain::biome::from_terrain(&tile.terrain); + let buf = tile.ensure_layer(LAYER_BIOME, DataSize::Byte); + if let crate::model::tile::LayerBuffer::Byte(bytes) = buf { + bytes.copy_from_slice(&biome_data); + } + + noise::CavesGenerator.generate(tile, seed); + noise::RiverGenerator.generate(tile, seed); + noise::FrostGenerator.generate(tile, seed); + terrain::TreesGenerator.generate(tile, seed); + terrain::ResourcesGenerator.generate(tile, seed); +} + +#[cfg(test)] +mod tests { + use crate::model::layers::{ + LAYER_BIOME, LAYER_CAVES, LAYER_FROST, LAYER_RESOURCES, LAYER_RIVER, LAYER_TREES, + }; + use crate::model::tile::{LayerBuffer, Tile}; + + fn make_test_tile(height: i16, terrain: u8) -> Tile { + let mut tile = Tile::new(0, 0, -64, 320); + for h in tile.heightmap.iter_mut() { + *h = height; + } + for t in tile.terrain.iter_mut() { + *t = terrain; + } + tile + } + + #[test] + fn test_generate_all_layers_creates_all_buffers() { + let mut tile = make_test_tile(70, 1); + super::generate_all_layers(&mut tile, 42); + + assert!(tile.get_layer_data(LAYER_CAVES).is_some()); + assert!(tile.get_layer_data(LAYER_RIVER).is_some()); + assert!(tile.get_layer_data(LAYER_FROST).is_some()); + assert!(tile.get_layer_data(LAYER_TREES).is_some()); + assert!(tile.get_layer_data(LAYER_BIOME).is_some()); + assert!(tile.get_layer_data(LAYER_RESOURCES).is_some()); + } + + #[test] + fn test_biome_layer_matches_terrain() { + let mut tile = Tile::new(0, 0, -64, 320); + let terrain_map: [u8; 7] = [0, 1, 2, 3, 4, 5, 6]; + for (i, t) in tile.terrain.iter_mut().enumerate() { + *t = terrain_map[i % 7]; + } + + super::generate_all_layers(&mut tile, 42); + let data = tile.get_layer_data(LAYER_BIOME).unwrap(); + if let LayerBuffer::Byte(b) = data { + assert_eq!(b[0], 0, "desert → plains"); + assert_eq!(b[1], 0, "grass → plains"); + assert_eq!(b[2], 2, "forest → forest"); + assert_eq!(b[3], 3, "rock → taiga"); + assert_eq!(b[4], 1, "sand → desert"); + assert_eq!(b[5], 4, "swamp → swamp"); + assert_eq!(b[6], 8, "water → ocean"); + } else { + panic!("biome layer should be Byte"); + } + } + + #[test] + fn test_frost_above_snow_line() { + let mut tile = make_test_tile(95, 1); + super::generate_all_layers(&mut tile, 42); + + let data = tile.get_layer_data(LAYER_FROST).unwrap(); + if let LayerBuffer::Bit(bits) = data { + for &word in bits.iter() { + assert_eq!(word, u64::MAX); + } + } else { + panic!("frost layer should be Bit"); + } + } + + #[test] + fn test_frost_below_snow_line() { + let mut tile = make_test_tile(50, 1); + super::generate_all_layers(&mut tile, 42); + + let data = tile.get_layer_data(LAYER_FROST).unwrap(); + if let LayerBuffer::Bit(bits) = data { + for &word in bits.iter() { + assert_eq!(word, 0); + } + } else { + panic!("frost layer should be Bit"); + } + } + + #[test] + fn test_river_water_terrain_no_river() { + let mut tile = make_test_tile(63, 6); + super::generate_all_layers(&mut tile, 42); + + let data = tile.get_layer_data(LAYER_RIVER).unwrap(); + if let LayerBuffer::Byte(bytes) = data { + for &b in bytes.iter() { + assert_eq!(b, 0); + } + } else { + panic!("river layer should be Byte"); + } + } + + #[test] + fn test_trees_forest_terrain_has_trees() { + let mut tile = make_test_tile(70, 2); + super::generate_all_layers(&mut tile, 42); + + let data = tile.get_layer_data(LAYER_TREES).unwrap(); + if let LayerBuffer::Byte(bytes) = data { + assert!(bytes.iter().any(|&b| b > 0)); + } else { + panic!("trees layer should be Byte"); + } + } + + #[test] + fn test_caves_no_caves_in_water() { + let mut tile = make_test_tile(-10, 6); + super::generate_all_layers(&mut tile, 42); + + let data = tile.get_layer_data(LAYER_CAVES).unwrap(); + if let LayerBuffer::Nibble(nibbles) = data { + for &n in nibbles.iter() { + assert_eq!(n, 0); + } + } else { + panic!("caves layer should be Nibble"); + } + } + + #[test] + fn test_caves_in_rock_terrain() { + let mut tile = make_test_tile(60, 3); + super::generate_all_layers(&mut tile, 42); + + let data = tile.get_layer_data(LAYER_CAVES).unwrap(); + if let LayerBuffer::Nibble(nibbles) = data { + assert!(nibbles.iter().any(|&n| n > 0)); + } else { + panic!("caves layer should be Nibble"); + } + } + + #[test] + fn test_resources_layer_is_empty() { + let mut tile = make_test_tile(70, 3); + super::generate_all_layers(&mut tile, 42); + + let data = tile.get_layer_data(LAYER_RESOURCES).unwrap(); + if let LayerBuffer::Nibble(nibbles) = data { + for &n in nibbles.iter() { + assert_eq!(n, 0); + } + } else { + panic!("resources layer should be Nibble"); + } + } +} diff --git a/core/src/ops/layers/noise/caves.rs b/core/src/ops/layers/noise/caves.rs new file mode 100644 index 0000000..b44ec9f --- /dev/null +++ b/core/src/ops/layers/noise/caves.rs @@ -0,0 +1,70 @@ +use terrafier_noise::{NoiseFn, OpenSimplex}; + +use crate::coords::TILE_SIZE; +use crate::model::layers::{DataSize, LAYER_CAVES}; +use crate::model::tile::Tile; +use crate::model::types::Terrain; + +pub struct CavesGenerator; + +impl crate::ops::layers::LayerGenerator for CavesGenerator { + fn layer_id(&self) -> u32 { + LAYER_CAVES + } + + fn generate(&self, tile: &mut Tile, seed: u64) { + let seed_u32 = (seed ^ (seed >> 32)) as u32; + let noise = OpenSimplex::new(seed_u32.wrapping_add(1)); + + // Read all inputs before mutating tile + let tile_x = tile.x; + let tile_z = tile.z; + let heightmap = tile.heightmap; + let terrain = tile.terrain; + + let total = TILE_SIZE * TILE_SIZE; + let nibble_count = total.div_ceil(2); + let mut nibbles = vec![0u8; nibble_count]; + + for lz in 0..TILE_SIZE { + for lx in 0..TILE_SIZE { + let idx = lz * TILE_SIZE + lx; + let height = heightmap[idx]; + let terrain_type = terrain[idx] as usize; + + if terrain_type == Terrain::Water as usize || height <= 0 { + continue; + } + + let world_x = (tile_x as f64 * TILE_SIZE as f64 + lx as f64) * 0.02; + let world_z = (tile_z as f64 * TILE_SIZE as f64 + lz as f64) * 0.02; + + let mut max_value: u8 = 0; + let surface_min = (height - 20).max(-60); + let surface_max = (height - 3).max(surface_min + 1); + + for y in (surface_min..=surface_max).step_by(2) { + let n = noise.get([world_x, world_z, y as f64 * 0.03]); + if n > 0.3 { + let v = ((n - 0.3) * 20.0) as u8; + if v > max_value { + max_value = v; + } + } + } + + let nibble_idx = idx / 2; + if idx.is_multiple_of(2) { + nibbles[nibble_idx] = (nibbles[nibble_idx] & 0x0F) | (max_value << 4); + } else { + nibbles[nibble_idx] = (nibbles[nibble_idx] & 0xF0) | max_value; + } + } + } + + let buf = tile.ensure_layer(LAYER_CAVES, DataSize::Nibble); + if let crate::model::tile::LayerBuffer::Nibble(n) = buf { + n.copy_from_slice(&nibbles); + } + } +} diff --git a/core/src/ops/layers/noise/frost.rs b/core/src/ops/layers/noise/frost.rs new file mode 100644 index 0000000..22c6ce8 --- /dev/null +++ b/core/src/ops/layers/noise/frost.rs @@ -0,0 +1,37 @@ +use crate::coords::TILE_SIZE; +use crate::model::layers::{DataSize, LAYER_FROST}; +use crate::model::tile::Tile; + +const SNOW_LINE: i16 = 90; + +pub struct FrostGenerator; + +impl crate::ops::layers::LayerGenerator for FrostGenerator { + fn layer_id(&self) -> u32 { + LAYER_FROST + } + + fn generate(&self, tile: &mut Tile, _seed: u64) { + let heightmap = tile.heightmap; + + let total = TILE_SIZE * TILE_SIZE; + let word_count = total.div_ceil(64); + let mut bits = vec![0u64; word_count]; + + for lz in 0..TILE_SIZE { + for lx in 0..TILE_SIZE { + let idx = lz * TILE_SIZE + lx; + if heightmap[idx] >= SNOW_LINE { + let word_idx = idx / 64; + let bit_idx = idx % 64; + bits[word_idx] |= 1u64 << bit_idx; + } + } + } + + let buf = tile.ensure_layer(LAYER_FROST, DataSize::Bit); + if let crate::model::tile::LayerBuffer::Bit(b) = buf { + b.copy_from_slice(&bits); + } + } +} diff --git a/core/src/ops/layers/noise/mod.rs b/core/src/ops/layers/noise/mod.rs new file mode 100644 index 0000000..b5e638d --- /dev/null +++ b/core/src/ops/layers/noise/mod.rs @@ -0,0 +1,7 @@ +mod caves; +mod frost; +mod river; + +pub use caves::CavesGenerator; +pub use frost::FrostGenerator; +pub use river::RiverGenerator; diff --git a/core/src/ops/layers/noise/river.rs b/core/src/ops/layers/noise/river.rs new file mode 100644 index 0000000..6b8e8ec --- /dev/null +++ b/core/src/ops/layers/noise/river.rs @@ -0,0 +1,62 @@ +use terrafier_noise::{NoiseFn, OpenSimplex}; + +use crate::coords::TILE_SIZE; +use crate::model::layers::{DataSize, LAYER_RIVER}; +use crate::model::tile::Tile; +use crate::model::types::Terrain; + +pub struct RiverGenerator; + +impl crate::ops::layers::LayerGenerator for RiverGenerator { + fn layer_id(&self) -> u32 { + LAYER_RIVER + } + + fn generate(&self, tile: &mut Tile, seed: u64) { + let seed_u32 = (seed ^ (seed >> 32)) as u32; + let noise = OpenSimplex::new(seed_u32.wrapping_add(2)); + + // Read all inputs before mutating tile + let tile_x = tile.x; + let tile_z = tile.z; + let heightmap = tile.heightmap; + let terrain = tile.terrain; + + let total = TILE_SIZE * TILE_SIZE; + let mut bytes = vec![0u8; total]; + + let tile_wx = tile_x as f64 * TILE_SIZE as f64; + let tile_wz = tile_z as f64 * TILE_SIZE as f64; + + for lz in 0..TILE_SIZE { + for lx in 0..TILE_SIZE { + let idx = lz * TILE_SIZE + lx; + let height = heightmap[idx]; + let terrain_type = terrain[idx] as usize; + + if terrain_type == Terrain::Water as usize || height <= 0 { + continue; + } + + let world_x = tile_wx + lx as f64; + let world_z = tile_wz + lz as f64; + + let base_offset = noise.get([world_x * 0.005, world_z * 0.005]) * 15.0; + let base_height = 55.0 + base_offset; + let river_noise = noise.get([world_x * 0.02 + 100.0, world_z * 0.02 + 100.0]); + + if (height as f64) < base_height && river_noise > 0.1 { + let depth = (base_height - height as f64).clamp(1.0, 8.0) as u8; + bytes[idx] = depth; + } else { + bytes[idx] = 0; + } + } + } + + let buf = tile.ensure_layer(LAYER_RIVER, DataSize::Byte); + if let crate::model::tile::LayerBuffer::Byte(b) = buf { + b.copy_from_slice(&bytes); + } + } +} diff --git a/core/src/ops/layers/terrain/biome.rs b/core/src/ops/layers/terrain/biome.rs new file mode 100644 index 0000000..fcfd946 --- /dev/null +++ b/core/src/ops/layers/terrain/biome.rs @@ -0,0 +1,59 @@ +use crate::coords::TILE_SIZE; +use crate::model::layers::{DataSize, LAYER_BIOME}; +use crate::model::tile::Tile; +use crate::ops::layers::LayerGenerator; + +pub struct BiomeGenerator; + +impl LayerGenerator for BiomeGenerator { + fn layer_id(&self) -> u32 { + LAYER_BIOME + } + + fn generate(&self, tile: &mut Tile, _seed: u64) { + let data = from_terrain(&tile.terrain); + let buf = tile.ensure_layer(LAYER_BIOME, DataSize::Byte); + if let crate::model::tile::LayerBuffer::Byte(bytes) = buf { + bytes.copy_from_slice(&data); + } + } +} + +/// Map terrain types to biome indices used by BiomeLayerExport. +/// Biome index → MC biome ID mapping (from biome_res.rs): +/// 0 → 1 (Plains), 1 → 2 (Desert), 2 → 4 (Forest), +/// 3 → 5 (Taiga), 4 → 6 (Swamp), 8 → 0 (Ocean) +const TERRAIN_TO_BIOME: [u8; 7] = [ + 0, // Desert → Plains (index 0) + 0, // Grass → Plains (index 0) + 2, // Forest → Forest (index 2) + 3, // Rock → Taiga (index 3) + 1, // Sand → Desert (index 1) + 4, // Swamp → Swamp (index 4) + 8, // Water → Ocean (index 8) +]; + +pub fn from_terrain(terrain: &[u8]) -> Vec { + terrain + .iter() + .map(|&t| { + let idx = t as usize; + if idx < TERRAIN_TO_BIOME.len() { + TERRAIN_TO_BIOME[idx] + } else { + 0 + } + }) + .collect() +} + +#[allow(dead_code)] +pub fn biome_at(tile: &Tile, local_x: usize, local_z: usize) -> u8 { + let idx = local_z * TILE_SIZE + local_x; + let terrain = tile.terrain[idx] as usize; + if terrain < TERRAIN_TO_BIOME.len() { + TERRAIN_TO_BIOME[terrain] + } else { + 0 + } +} diff --git a/core/src/ops/layers/terrain/mod.rs b/core/src/ops/layers/terrain/mod.rs new file mode 100644 index 0000000..5714058 --- /dev/null +++ b/core/src/ops/layers/terrain/mod.rs @@ -0,0 +1,7 @@ +pub mod biome; +mod resources; +mod trees; + +pub use biome::BiomeGenerator; +pub use resources::ResourcesGenerator; +pub use trees::TreesGenerator; diff --git a/core/src/ops/layers/terrain/resources.rs b/core/src/ops/layers/terrain/resources.rs new file mode 100644 index 0000000..744f3bb --- /dev/null +++ b/core/src/ops/layers/terrain/resources.rs @@ -0,0 +1,16 @@ +use crate::model::layers::{DataSize, LAYER_RESOURCES}; +use crate::model::tile::Tile; + +pub struct ResourcesGenerator; + +impl crate::ops::layers::LayerGenerator for ResourcesGenerator { + fn layer_id(&self) -> u32 { + LAYER_RESOURCES + } + + fn generate(&self, tile: &mut Tile, _seed: u64) { + // Resources layer is reserved for manual painting by the user. + // Initialize with zeros (no ores). + tile.ensure_layer(LAYER_RESOURCES, DataSize::Nibble); + } +} diff --git a/core/src/ops/layers/terrain/trees.rs b/core/src/ops/layers/terrain/trees.rs new file mode 100644 index 0000000..25ceab9 --- /dev/null +++ b/core/src/ops/layers/terrain/trees.rs @@ -0,0 +1,52 @@ +use crate::coords::TILE_SIZE; +use crate::model::layers::{DataSize, LAYER_TREES}; +use crate::model::tile::Tile; +use crate::model::types::Terrain; +use std::hash::{Hash, Hasher}; + +fn tile_hash(tile_x: i32, tile_z: i32, seed: u64) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + tile_x.hash(&mut hasher); + tile_z.hash(&mut hasher); + seed.hash(&mut hasher); + hasher.finish() +} + +fn pseudo_rand(state: &mut u64) -> u64 { + *state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + *state >> 33 +} + +pub struct TreesGenerator; + +impl crate::ops::layers::LayerGenerator for TreesGenerator { + fn layer_id(&self) -> u32 { + LAYER_TREES + } + + fn generate(&self, tile: &mut Tile, seed: u64) { + let terrain = tile.terrain; + let mut rng = tile_hash(tile.x, tile.z, seed); + + let total = TILE_SIZE * TILE_SIZE; + let mut bytes = vec![0u8; total]; + + for idx in 0..total { + let terrain_type = terrain[idx] as usize; + bytes[idx] = match terrain_type { + t if t == Terrain::Forest as usize => { + (pseudo_rand(&mut rng) % 60).max(20) as u8 + } + t if t == Terrain::Grass as usize && pseudo_rand(&mut rng) % 100 < 30 => { + (pseudo_rand(&mut rng) % 20 + 5) as u8 + } + _ => 0, + }; + } + + let buf = tile.ensure_layer(LAYER_TREES, DataSize::Byte); + if let crate::model::tile::LayerBuffer::Byte(b) = buf { + b.copy_from_slice(&bytes); + } + } +} diff --git a/core/src/ops/mod.rs b/core/src/ops/mod.rs index 09b2f6b..bad794e 100755 --- a/core/src/ops/mod.rs +++ b/core/src/ops/mod.rs @@ -1,5 +1,6 @@ -//! Editing operations — terrain modification, brush operations, height maps. +//! Editing operations — terrain modification, brush operations, height maps, layer generation. pub mod filters; pub mod heightmap; +pub mod layers; pub mod operations; diff --git a/core/src/ops/operations.rs b/core/src/ops/operations.rs deleted file mode 100755 index bd05c26..0000000 --- a/core/src/ops/operations.rs +++ /dev/null @@ -1,843 +0,0 @@ -//! Editing operations — raise, lower, smooth, flatten, etc. -//! -//! Each operation implements the Operation trait and supports undo. - -use std::collections::VecDeque; -use std::sync::Arc; -use std::sync::OnceLock; - -use crate::model::dimension::Dimension; -use crate::model::terrain::Terrain; -use crate::model::tile::TILE_SIZE; - -/// An operation that can be applied and reverted. -pub trait Operation: Send + Sync { - fn name(&self) -> &'static str; - fn apply(&self, dim: &mut Dimension) -> Result<(), OperationError>; - fn inverse(&self) -> Box; -} - -#[derive(Debug)] -pub enum OperationError { - InvalidParameters(String), - OutOfBounds { tx: i32, tz: i32, x: u32, z: u32 }, -} - -/// Raise or lower terrain height within a brush area. -pub struct HeightOperation { - pub tile_x: i32, - pub tile_z: i32, - pub center_x: u32, - pub center_z: u32, - pub radius: u32, - pub delta: i16, - pub brush: Arc, - pub before_snapshot: OnceLock>, -} - -impl Operation for HeightOperation { - fn name(&self) -> &'static str { - if self.delta >= 0 { "Raise" } else { "Lower" } - } - - fn apply(&self, dim: &mut Dimension) -> Result<(), OperationError> { - let tile = - dim.tiles - .get_mut(&(self.tile_x, self.tile_z)) - .ok_or(OperationError::OutOfBounds { - tx: self.tile_x, - tz: self.tile_z, - x: 0, - z: 0, - })?; - - let r = self.radius as i32; - let cx = self.center_x as i32; - let cz = self.center_z as i32; - - let mut heights: Vec<(usize, i16)> = Vec::new(); - - for dz in -r..=r { - for dx in -r..=r { - let ax = cx + dx; - let az = cz + dz; - if ax < 0 || az < 0 || ax >= TILE_SIZE as i32 || az >= TILE_SIZE as i32 { - continue; - } - let strength = self.brush.get_strength(dx as f64, dz as f64); - if strength <= 0.0 { - continue; - } - - let idx = (az as usize) * TILE_SIZE + (ax as usize); - - // Save original height - if self.before_snapshot.get().is_none() { - heights.push((idx, tile.heightmap[idx])); - } - - let change = (self.delta as f64 * strength).round() as i16; - let new_height = - (tile.heightmap[idx] + change).clamp(tile.min_height, tile.max_height); - tile.heightmap[idx] = new_height; - } - } - - if self.before_snapshot.get().is_none() { - let _ = self.before_snapshot.set(heights); - } - - Ok(()) - } - - fn inverse(&self) -> Box { - let snapshot = self.before_snapshot.get().cloned().unwrap_or_default(); - Box::new(RestoreHeightsOperation { - tile_x: self.tile_x, - tile_z: self.tile_z, - snapshot, - }) - } -} - -/// Flatten terrain to a target height within a brush area. -pub struct FlattenOperation { - pub tile_x: i32, - pub tile_z: i32, - pub center_x: u32, - pub center_z: u32, - pub radius: u32, - pub target_height: i16, - pub brush: Arc, - pub before_snapshot: OnceLock>, -} - -impl Operation for FlattenOperation { - fn name(&self) -> &'static str { - "Flatten" - } - - fn apply(&self, dim: &mut Dimension) -> Result<(), OperationError> { - let tile = - dim.tiles - .get_mut(&(self.tile_x, self.tile_z)) - .ok_or(OperationError::OutOfBounds { - tx: self.tile_x, - tz: self.tile_z, - x: 0, - z: 0, - })?; - - let r = self.radius as i32; - let cx = self.center_x as i32; - let cz = self.center_z as i32; - - // Phase 1: collect (idx, original_height) snapshot + apply modifications - let mut heights: Vec<(usize, i16)> = Vec::new(); - - for dz in -r..=r { - for dx in -r..=r { - let ax = cx + dx; - let az = cz + dz; - if ax < 0 || az < 0 || ax >= TILE_SIZE as i32 || az >= TILE_SIZE as i32 { - continue; - } - let strength = self.brush.get_strength(dx as f64, dz as f64); - if strength <= 0.0 { - continue; - } - - let idx = (az as usize) * TILE_SIZE + (ax as usize); - - // Save original height for undo - if self.before_snapshot.get().is_none() { - heights.push((idx, tile.heightmap[idx])); - } - - let current = tile.heightmap[idx]; - let diff = self.target_height as i32 - current as i32; - let change = (diff as f64 * strength).round() as i32; - let new_height = (current as i32 + change) - .clamp(tile.min_height as i32, tile.max_height as i32) - as i16; - tile.heightmap[idx] = new_height; - } - } - - // Store snapshot on first call - if self.before_snapshot.get().is_none() { - let _ = self.before_snapshot.set(heights); - } - - Ok(()) - } - - fn inverse(&self) -> Box { - let snapshot = self.before_snapshot.get().cloned().unwrap_or_default(); - Box::new(RestoreHeightsOperation { - tile_x: self.tile_x, - tile_z: self.tile_z, - snapshot, - }) - } -} - -/// Paint terrain type within a brush area. -pub struct PaintOperation { - pub tile_x: i32, - pub tile_z: i32, - pub center_x: u32, - pub center_z: u32, - pub radius: u32, - pub terrain: Terrain, - pub brush: Arc, - pub before_snapshot: OnceLock>, -} - -impl Operation for PaintOperation { - fn name(&self) -> &'static str { - "Paint" - } - - fn apply(&self, dim: &mut Dimension) -> Result<(), OperationError> { - let tile = - dim.tiles - .get_mut(&(self.tile_x, self.tile_z)) - .ok_or(OperationError::OutOfBounds { - tx: self.tile_x, - tz: self.tile_z, - x: 0, - z: 0, - })?; - - let r = self.radius as i32; - let cx = self.center_x as i32; - let cz = self.center_z as i32; - let terrain_id = self.terrain as u8; - - let mut snapshot: Vec<(usize, u8)> = Vec::new(); - - for dz in -r..=r { - for dx in -r..=r { - let ax = cx + dx; - let az = cz + dz; - if ax < 0 || az < 0 || ax >= TILE_SIZE as i32 || az >= TILE_SIZE as i32 { - continue; - } - if self.brush.get_strength(dx as f64, dz as f64) > 0.0 { - let idx = (az as usize) * TILE_SIZE + (ax as usize); - if self.before_snapshot.get().is_none() { - snapshot.push((idx, tile.terrain[idx])); - } - tile.terrain[idx] = terrain_id; - } - } - } - - if self.before_snapshot.get().is_none() { - let _ = self.before_snapshot.set(snapshot); - } - - Ok(()) - } - - fn inverse(&self) -> Box { - let snapshot = self.before_snapshot.get().cloned().unwrap_or_default(); - Box::new(RestoreTerrainOperation { - tile_x: self.tile_x, - tile_z: self.tile_z, - snapshot, - }) - } -} - -/// Restores heights from a saved snapshot (used as inverse of FlattenOperation). -pub struct RestoreHeightsOperation { - pub tile_x: i32, - pub tile_z: i32, - pub snapshot: Vec<(usize, i16)>, -} - -impl Operation for RestoreHeightsOperation { - fn name(&self) -> &'static str { - "RestoreHeights" - } - - fn apply(&self, dim: &mut Dimension) -> Result<(), OperationError> { - let tile = - dim.tiles - .get_mut(&(self.tile_x, self.tile_z)) - .ok_or(OperationError::OutOfBounds { - tx: self.tile_x, - tz: self.tile_z, - x: 0, - z: 0, - })?; - for &(idx, h) in &self.snapshot { - if idx < tile.heightmap.len() { - tile.heightmap[idx] = h; - } - } - Ok(()) - } - - fn inverse(&self) -> Box { - Box::new(NoOpOperation) - } -} - -/// Restores terrain values from a saved snapshot (used as inverse of PaintOperation). -pub struct RestoreTerrainOperation { - pub tile_x: i32, - pub tile_z: i32, - pub snapshot: Vec<(usize, u8)>, -} - -impl Operation for RestoreTerrainOperation { - fn name(&self) -> &'static str { - "RestoreTerrain" - } - - fn apply(&self, dim: &mut Dimension) -> Result<(), OperationError> { - let tile = - dim.tiles - .get_mut(&(self.tile_x, self.tile_z)) - .ok_or(OperationError::OutOfBounds { - tx: self.tile_x, - tz: self.tile_z, - x: 0, - z: 0, - })?; - for &(idx, t) in &self.snapshot { - if idx < tile.terrain.len() { - tile.terrain[idx] = t; - } - } - Ok(()) - } - - fn inverse(&self) -> Box { - Box::new(NoOpOperation) - } -} - -/// Smooth terrain by averaging heights in a 3x3 neighborhood. -pub struct SmoothOperation { - pub tile_x: i32, - pub tile_z: i32, - pub center_x: u32, - pub center_z: u32, - pub radius: u32, - pub iterations: u32, - pub brush: Arc, - pub before_snapshot: OnceLock>, -} - -impl Operation for SmoothOperation { - fn name(&self) -> &'static str { - "Smooth" - } - - fn apply(&self, dim: &mut Dimension) -> Result<(), OperationError> { - let tile = - dim.tiles - .get_mut(&(self.tile_x, self.tile_z)) - .ok_or(OperationError::OutOfBounds { - tx: self.tile_x, - tz: self.tile_z, - x: 0, - z: 0, - })?; - - let r = self.radius as i32; - let cx = self.center_x as i32; - let cz = self.center_z as i32; - - // Snapshot original heights - let mut snapshot: Vec<(usize, i16)> = Vec::new(); - for dz in -r..=r { - for dx in -r..=r { - let ax = cx + dx; - let az = cz + dz; - if ax < 0 || az < 0 || ax >= TILE_SIZE as i32 || az >= TILE_SIZE as i32 { - continue; - } - if self.brush.get_strength(dx as f64, dz as f64) <= 0.0 { - continue; - } - if self.before_snapshot.get().is_none() { - snapshot.push(( - (az as usize) * TILE_SIZE + (ax as usize), - tile.heightmap[(az as usize) * TILE_SIZE + (ax as usize)], - )); - } - } - } - if self.before_snapshot.get().is_none() { - let _ = self.before_snapshot.set(snapshot); - } - - // Apply smoothing for `iterations` passes - for _ in 0..self.iterations.max(1) { - // Copy current heights into temp buffer - let mut new_heights = tile.heightmap; - - for dz in -r..=r { - for dx in -r..=r { - let ax = cx + dx; - let az = cz + dz; - if ax < 0 || az < 0 || ax >= TILE_SIZE as i32 || az >= TILE_SIZE as i32 { - continue; - } - if self.brush.get_strength(dx as f64, dz as f64) <= 0.0 { - continue; - } - - let idx = (az as usize) * TILE_SIZE + (ax as usize); - - // Average of 3x3 neighborhood (clamped to tile bounds) - let mut sum = 0i32; - let mut count = 0i32; - for ny in -1..=1i32 { - for nx in -1..=1i32 { - let bx = ax + nx; - let bz = az + ny; - if bx >= 0 && bx < TILE_SIZE as i32 && bz >= 0 && bz < TILE_SIZE as i32 - { - sum += tile.heightmap[(bz as usize) * TILE_SIZE + (bx as usize)] - as i32; - count += 1; - } - } - } - if count > 0 { - new_heights[idx] = (sum / count) - .clamp(tile.min_height as i32, tile.max_height as i32) - as i16; - } - } - } - - tile.heightmap = new_heights; - } - - Ok(()) - } - - fn inverse(&self) -> Box { - let snapshot = self.before_snapshot.get().cloned().unwrap_or_default(); - Box::new(RestoreHeightsOperation { - tile_x: self.tile_x, - tile_z: self.tile_z, - snapshot, - }) - } -} - -/// Simulate thermal erosion — material moves from high to low when slope exceeds threshold. -pub struct ErodeOperation { - pub tile_x: i32, - pub tile_z: i32, - pub center_x: u32, - pub center_z: u32, - pub radius: u32, - pub iterations: u32, - pub talus_angle: f64, - pub brush: Arc, - pub before_snapshot: OnceLock>, -} - -impl Operation for ErodeOperation { - fn name(&self) -> &'static str { - "Erode" - } - - fn apply(&self, dim: &mut Dimension) -> Result<(), OperationError> { - let tile = - dim.tiles - .get_mut(&(self.tile_x, self.tile_z)) - .ok_or(OperationError::OutOfBounds { - tx: self.tile_x, - tz: self.tile_z, - x: 0, - z: 0, - })?; - - let r = self.radius as i32; - let cx = self.center_x as i32; - let cz = self.center_z as i32; - - // Snapshot original heights on first apply - let mut snapshot: Vec<(usize, i16)> = Vec::new(); - let is_first_apply = self.before_snapshot.get().is_none(); - if is_first_apply { - for dz in -r..=r { - for dx in -r..=r { - let ax = cx + dx; - let az = cz + dz; - if ax < 0 || az < 0 || ax >= TILE_SIZE as i32 || az >= TILE_SIZE as i32 { - continue; - } - if self.brush.get_strength(dx as f64, dz as f64) <= 0.0 { - continue; - } - let idx = (az as usize) * TILE_SIZE + (ax as usize); - snapshot.push((idx, tile.heightmap[idx])); - } - } - } - if is_first_apply { - let _ = self.before_snapshot.set(snapshot); - } - - // Apply erosion for `iterations` passes - let tile_size = TILE_SIZE; - for _ in 0..self.iterations.max(1) { - let mut deltas = vec![0i32; tile_size * tile_size]; - - for dz in -r..=r { - for dx in -r..=r { - let ax = cx + dx; - let az = cz + dz; - if ax < 0 || az < 0 || ax >= tile_size as i32 || az >= tile_size as i32 { - continue; - } - if self.brush.get_strength(dx as f64, dz as f64) <= 0.0 { - continue; - } - - let idx = (az as usize) * tile_size + (ax as usize); - let current = tile.heightmap[idx] as i32; - - // Find lowest neighbor that is also in brush area - let mut lowest_h = current; - let mut lowest_idx = None; - for (ndx, ndz) in [(0, -1), (0, 1), (-1, 0), (1, 0)] { - let bx = ax + ndx; - let bz = az + ndz; - if bx < 0 || bx >= tile_size as i32 || bz < 0 || bz >= tile_size as i32 { - continue; - } - let n_dx = bx - cx; - let n_dz = bz - cz; - if self.brush.get_strength(n_dx as f64, n_dz as f64) <= 0.0 { - continue; - } - let nidx = (bz as usize) * tile_size + (bx as usize); - let nh = tile.heightmap[nidx] as i32; - if nh < lowest_h { - lowest_h = nh; - lowest_idx = Some(nidx); - } - } - - if let Some(lidx) = lowest_idx { - let diff = current - lowest_h; - if (diff as f64) > self.talus_angle { - let excess = diff as f64 - self.talus_angle; - let move_amount = (excess * 0.5).round() as i32; - deltas[idx] -= move_amount; - deltas[lidx] += move_amount; - } - } - } - } - - // Apply deltas - for dz in -r..=r { - for dx in -r..=r { - let ax = cx + dx; - let az = cz + dz; - if ax < 0 || az < 0 || ax >= tile_size as i32 || az >= tile_size as i32 { - continue; - } - if self.brush.get_strength(dx as f64, dz as f64) <= 0.0 { - continue; - } - let idx = (az as usize) * tile_size + (ax as usize); - if deltas[idx] != 0 { - let new_h = (tile.heightmap[idx] as i32 + deltas[idx]) - .clamp(tile.min_height as i32, tile.max_height as i32) - as i16; - tile.heightmap[idx] = new_h; - } - } - } - } - - Ok(()) - } - - fn inverse(&self) -> Box { - let snapshot = self.before_snapshot.get().cloned().unwrap_or_default(); - Box::new(RestoreHeightsOperation { - tile_x: self.tile_x, - tile_z: self.tile_z, - snapshot, - }) - } -} - -/// Fill a contiguous area (flood-fill from center) with a terrain type. -pub struct FillOperation { - pub tile_x: i32, - pub tile_z: i32, - pub center_x: u32, - pub center_z: u32, - pub terrain: Terrain, - pub before_snapshot: OnceLock>, -} - -impl Operation for FillOperation { - fn name(&self) -> &'static str { - "Fill" - } - - fn apply(&self, dim: &mut Dimension) -> Result<(), OperationError> { - let tile = - dim.tiles - .get_mut(&(self.tile_x, self.tile_z)) - .ok_or(OperationError::OutOfBounds { - tx: self.tile_x, - tz: self.tile_z, - x: 0, - z: 0, - })?; - - let cx = self.center_x as usize; - let cz = self.center_z as usize; - if cx >= TILE_SIZE || cz >= TILE_SIZE { - return Err(OperationError::OutOfBounds { - tx: self.tile_x, - tz: self.tile_z, - x: self.center_x, - z: self.center_z, - }); - } - - let idx = cz * TILE_SIZE + cx; - let original_terrain = tile.terrain[idx]; - let target_terrain = self.terrain as u8; - - if original_terrain == target_terrain { - return Ok(()); - } - - let mut snapshot: Vec<(usize, u8)> = Vec::new(); - let mut visited = vec![false; TILE_SIZE * TILE_SIZE]; - let mut queue = VecDeque::new(); - queue.push_back((cx, cz)); - visited[idx] = true; - - while let Some((x, z)) = queue.pop_front() { - let i = z * TILE_SIZE + x; - if self.before_snapshot.get().is_none() { - snapshot.push((i, tile.terrain[i])); - } - tile.terrain[i] = target_terrain; - - for (ndx, ndz) in [(0, -1), (0, 1), (-1, 0), (1, 0)] { - let nx = x as i32 + ndx; - let nz = z as i32 + ndz; - if nx >= 0 && nx < TILE_SIZE as i32 && nz >= 0 && nz < TILE_SIZE as i32 { - let nxu = nx as usize; - let nzu = nz as usize; - let ni = nzu * TILE_SIZE + nxu; - if !visited[ni] && tile.terrain[ni] == original_terrain { - visited[ni] = true; - queue.push_back((nxu, nzu)); - } - } - } - } - - if self.before_snapshot.get().is_none() { - let _ = self.before_snapshot.set(snapshot); - } - - Ok(()) - } - - fn inverse(&self) -> Box { - let snapshot = self.before_snapshot.get().cloned().unwrap_or_default(); - Box::new(RestoreTerrainOperation { - tile_x: self.tile_x, - tile_z: self.tile_z, - snapshot, - }) - } -} - -/// Set all terrain within brush area to a single type (hard replace regardless of strength). -pub struct FloodOperation { - pub tile_x: i32, - pub tile_z: i32, - pub center_x: u32, - pub center_z: u32, - pub radius: u32, - pub terrain: Terrain, - pub brush: Arc, - pub before_snapshot: OnceLock>, -} - -impl Operation for FloodOperation { - fn name(&self) -> &'static str { - "Flood" - } - - fn apply(&self, dim: &mut Dimension) -> Result<(), OperationError> { - let tile = - dim.tiles - .get_mut(&(self.tile_x, self.tile_z)) - .ok_or(OperationError::OutOfBounds { - tx: self.tile_x, - tz: self.tile_z, - x: 0, - z: 0, - })?; - - let r = self.radius as i32; - let cx = self.center_x as i32; - let cz = self.center_z as i32; - let terrain_id = self.terrain as u8; - - let mut snapshot: Vec<(usize, u8)> = Vec::new(); - - for dz in -r..=r { - for dx in -r..=r { - let ax = cx + dx; - let az = cz + dz; - if ax < 0 || az < 0 || ax >= TILE_SIZE as i32 || az >= TILE_SIZE as i32 { - continue; - } - if self.brush.get_strength(dx as f64, dz as f64) == 0.0 { - continue; - } - let idx = (az as usize) * TILE_SIZE + (ax as usize); - if self.before_snapshot.get().is_none() { - snapshot.push((idx, tile.terrain[idx])); - } - tile.terrain[idx] = terrain_id; - } - } - - if self.before_snapshot.get().is_none() { - let _ = self.before_snapshot.set(snapshot); - } - - Ok(()) - } - - fn inverse(&self) -> Box { - let snapshot = self.before_snapshot.get().cloned().unwrap_or_default(); - Box::new(RestoreTerrainOperation { - tile_x: self.tile_x, - tile_z: self.tile_z, - snapshot, - }) - } -} - -/// Single-point terrain paint (radius 1, no brush falloff). -pub struct PencilOperation { - pub tile_x: i32, - pub tile_z: i32, - pub center_x: u32, - pub center_z: u32, - pub terrain: Terrain, - pub before_snapshot: OnceLock>, -} - -impl Operation for PencilOperation { - fn name(&self) -> &'static str { - "Pencil" - } - - fn apply(&self, dim: &mut Dimension) -> Result<(), OperationError> { - let tile = - dim.tiles - .get_mut(&(self.tile_x, self.tile_z)) - .ok_or(OperationError::OutOfBounds { - tx: self.tile_x, - tz: self.tile_z, - x: 0, - z: 0, - })?; - - let cx = self.center_x as usize; - let cz = self.center_z as usize; - if cx >= TILE_SIZE || cz >= TILE_SIZE { - return Err(OperationError::OutOfBounds { - tx: self.tile_x, - tz: self.tile_z, - x: self.center_x, - z: self.center_z, - }); - } - - let idx = cz * TILE_SIZE + cx; - if self.before_snapshot.get().is_none() { - let _ = self.before_snapshot.set(vec![(idx, tile.terrain[idx])]); - } - tile.terrain[idx] = self.terrain as u8; - - Ok(()) - } - - fn inverse(&self) -> Box { - let snapshot = self.before_snapshot.get().cloned().unwrap_or_default(); - Box::new(RestoreTerrainOperation { - tile_x: self.tile_x, - tile_z: self.tile_z, - snapshot, - }) - } -} - -/// Applies multiple sub-operations, supporting brushes that span multiple tiles. -pub struct MultiTileOperation { - pub operations: Vec>, -} - -impl Operation for MultiTileOperation { - fn name(&self) -> &'static str { - if self.operations.is_empty() { - "MultiTile (empty)" - } else { - self.operations[0].name() - } - } - - fn apply(&self, dim: &mut Dimension) -> Result<(), OperationError> { - for op in &self.operations { - op.apply(dim)?; - } - Ok(()) - } - - fn inverse(&self) -> Box { - let mut inverses: Vec> = Vec::with_capacity(self.operations.len()); - for op in &self.operations { - inverses.push(op.inverse()); - } - inverses.reverse(); - Box::new(MultiTileOperation { - operations: inverses, - }) - } -} - -/// No-op operation (used as fallback inverse). -pub struct NoOpOperation; - -impl Operation for NoOpOperation { - fn name(&self) -> &'static str { - "NoOp" - } - fn apply(&self, _dim: &mut Dimension) -> Result<(), OperationError> { - Ok(()) - } - fn inverse(&self) -> Box { - Box::new(NoOpOperation) - } -} diff --git a/core/src/ops/operations/brush/mod.rs b/core/src/ops/operations/brush/mod.rs new file mode 100644 index 0000000..83c2d6d --- /dev/null +++ b/core/src/ops/operations/brush/mod.rs @@ -0,0 +1,2 @@ +mod paint; +pub use paint::*; diff --git a/core/src/ops/operations/brush/paint.rs b/core/src/ops/operations/brush/paint.rs new file mode 100644 index 0000000..b435221 --- /dev/null +++ b/core/src/ops/operations/brush/paint.rs @@ -0,0 +1,202 @@ +use std::sync::{Arc, OnceLock}; +use crate::model::types::Brush; +use crate::model::dimension::Dimension; +use crate::model::types::Terrain; +use crate::model::tile::TILE_SIZE; +use crate::ops::operations::{Operation, OperationError, RestoreTerrainOperation}; + +struct BrushApply<'a> { + tile: &'a mut crate::model::tile::Tile, + cx: i32, + cz: i32, + radius: u32, + terrain_id: u8, + brush: &'a dyn Brush, + snapshot: &'a OnceLock>, + snapshot_buf: &'a mut Vec<(usize, u8)>, +} + +fn apply_brush_terrain(args: BrushApply) { + let r = args.radius as i32; + for dz in -r..=r { + for dx in -r..=r { + let ax = args.cx + dx; + let az = args.cz + dz; + if ax < 0 || az < 0 || ax >= TILE_SIZE as i32 || az >= TILE_SIZE as i32 { + continue; + } + if args.brush.get_strength(dx as f64, dz as f64) == 0.0 { + continue; + } + let idx = (az as usize) * TILE_SIZE + (ax as usize); + if args.snapshot.get().is_none() { + args.snapshot_buf.push((idx, args.tile.terrain[idx])); + } + args.tile.terrain[idx] = args.terrain_id; + } + } +} + +/// Paint terrain type within a brush area. +pub struct PaintOperation { + pub tile_x: i32, + pub tile_z: i32, + pub center_x: u32, + pub center_z: u32, + pub radius: u32, + pub terrain: Terrain, + pub brush: Arc, + pub before_snapshot: OnceLock>, +} + +impl Operation for PaintOperation { + fn name(&self) -> &'static str { + "Paint" + } + + fn apply(&self, dim: &mut Dimension) -> Result<(), OperationError> { + let tile = + dim.tiles + .get_mut(&(self.tile_x, self.tile_z)) + .ok_or(OperationError::OutOfBounds { + tx: self.tile_x, + tz: self.tile_z, + x: 0, + z: 0, + })?; + + let mut snapshot_buf = Vec::new(); + apply_brush_terrain(BrushApply { + tile, + cx: self.center_x as i32, + cz: self.center_z as i32, + radius: self.radius, + terrain_id: self.terrain as u8, + brush: self.brush.as_ref(), + snapshot: &self.before_snapshot, + snapshot_buf: &mut snapshot_buf, + }); + + let _ = self.before_snapshot.get_or_init(|| snapshot_buf); + Ok(()) + } + + fn inverse(&self) -> Box { + let snapshot = self.before_snapshot.get().cloned().unwrap_or_default(); + Box::new(RestoreTerrainOperation { + tile_x: self.tile_x, + tile_z: self.tile_z, + snapshot, + }) + } +} + +/// Set all terrain within brush area to a single type (hard replace regardless of strength). +pub struct FloodOperation { + pub tile_x: i32, + pub tile_z: i32, + pub center_x: u32, + pub center_z: u32, + pub radius: u32, + pub terrain: Terrain, + pub brush: Arc, + pub before_snapshot: OnceLock>, +} + +impl Operation for FloodOperation { + fn name(&self) -> &'static str { + "Flood" + } + + fn apply(&self, dim: &mut Dimension) -> Result<(), OperationError> { + let tile = + dim.tiles + .get_mut(&(self.tile_x, self.tile_z)) + .ok_or(OperationError::OutOfBounds { + tx: self.tile_x, + tz: self.tile_z, + x: 0, + z: 0, + })?; + + let mut snapshot_buf = Vec::new(); + apply_brush_terrain(BrushApply { + tile, + cx: self.center_x as i32, + cz: self.center_z as i32, + radius: self.radius, + terrain_id: self.terrain as u8, + brush: self.brush.as_ref(), + snapshot: &self.before_snapshot, + snapshot_buf: &mut snapshot_buf, + }); + + let _ = self.before_snapshot.get_or_init(|| snapshot_buf); + Ok(()) + } + + fn inverse(&self) -> Box { + let snapshot = self.before_snapshot.get().cloned().unwrap_or_default(); + Box::new(RestoreTerrainOperation { + tile_x: self.tile_x, + tile_z: self.tile_z, + snapshot, + }) + } +} + +/// Single-point terrain paint (radius 1, no brush falloff). +pub struct PencilOperation { + pub tile_x: i32, + pub tile_z: i32, + pub center_x: u32, + pub center_z: u32, + pub terrain: Terrain, + pub before_snapshot: OnceLock>, +} + +impl Operation for PencilOperation { + fn name(&self) -> &'static str { + "Pencil" + } + + fn apply(&self, dim: &mut Dimension) -> Result<(), OperationError> { + let tile = + dim.tiles + .get_mut(&(self.tile_x, self.tile_z)) + .ok_or(OperationError::OutOfBounds { + tx: self.tile_x, + tz: self.tile_z, + x: 0, + z: 0, + })?; + + let cx = self.center_x as usize; + let cz = self.center_z as usize; + if cx >= TILE_SIZE || cz >= TILE_SIZE { + return Err(OperationError::OutOfBounds { + tx: self.tile_x, + tz: self.tile_z, + x: self.center_x, + z: self.center_z, + }); + } + + let idx = cz * TILE_SIZE + cx; + if self.before_snapshot.get().is_none() { + let _ = self.before_snapshot.set(vec![(idx, tile.terrain[idx])]); + } + tile.terrain[idx] = self.terrain as u8; + + Ok(()) + } + + fn inverse(&self) -> Box { + let snapshot = self.before_snapshot.get().cloned().unwrap_or_default(); + Box::new(RestoreTerrainOperation { + tile_x: self.tile_x, + tile_z: self.tile_z, + snapshot, + }) + } +} diff --git a/core/src/ops/operations/fill.rs b/core/src/ops/operations/fill.rs new file mode 100644 index 0000000..79c41f5 --- /dev/null +++ b/core/src/ops/operations/fill.rs @@ -0,0 +1,98 @@ +use std::collections::VecDeque; +use std::sync::OnceLock; + +use crate::model::dimension::Dimension; +use crate::model::types::Terrain; +use crate::model::tile::TILE_SIZE; + +use super::{Operation, OperationError, RestoreTerrainOperation}; + +/// Fill a contiguous area (flood-fill from center) with a terrain type. +pub struct FillOperation { + pub tile_x: i32, + pub tile_z: i32, + pub center_x: u32, + pub center_z: u32, + pub terrain: Terrain, + pub before_snapshot: OnceLock>, +} + +impl Operation for FillOperation { + fn name(&self) -> &'static str { + "Fill" + } + + fn apply(&self, dim: &mut Dimension) -> Result<(), OperationError> { + let tile = + dim.tiles + .get_mut(&(self.tile_x, self.tile_z)) + .ok_or(OperationError::OutOfBounds { + tx: self.tile_x, + tz: self.tile_z, + x: 0, + z: 0, + })?; + + let cx = self.center_x as usize; + let cz = self.center_z as usize; + if cx >= TILE_SIZE || cz >= TILE_SIZE { + return Err(OperationError::OutOfBounds { + tx: self.tile_x, + tz: self.tile_z, + x: self.center_x, + z: self.center_z, + }); + } + + let idx = cz * TILE_SIZE + cx; + let original_terrain = tile.terrain[idx]; + let target_terrain = self.terrain as u8; + + if original_terrain == target_terrain { + return Ok(()); + } + + let mut snapshot: Vec<(usize, u8)> = Vec::new(); + let mut visited = vec![false; TILE_SIZE * TILE_SIZE]; + let mut queue = VecDeque::new(); + queue.push_back((cx, cz)); + visited[idx] = true; + + while let Some((x, z)) = queue.pop_front() { + let i = z * TILE_SIZE + x; + if self.before_snapshot.get().is_none() { + snapshot.push((i, tile.terrain[i])); + } + tile.terrain[i] = target_terrain; + + for (ndx, ndz) in [(0, -1), (0, 1), (-1, 0), (1, 0)] { + let nx = x as i32 + ndx; + let nz = z as i32 + ndz; + if nx >= 0 && nx < TILE_SIZE as i32 && nz >= 0 && nz < TILE_SIZE as i32 { + let nxu = nx as usize; + let nzu = nz as usize; + let ni = nzu * TILE_SIZE + nxu; + if !visited[ni] && tile.terrain[ni] == original_terrain { + visited[ni] = true; + queue.push_back((nxu, nzu)); + } + } + } + } + + if self.before_snapshot.get().is_none() { + let _ = self.before_snapshot.set(snapshot); + } + + Ok(()) + } + + fn inverse(&self) -> Box { + let snapshot = self.before_snapshot.get().cloned().unwrap_or_default(); + Box::new(RestoreTerrainOperation { + tile_x: self.tile_x, + tile_z: self.tile_z, + snapshot, + }) + } +} diff --git a/core/src/ops/operations/mod.rs b/core/src/ops/operations/mod.rs new file mode 100644 index 0000000..d77ccfa --- /dev/null +++ b/core/src/ops/operations/mod.rs @@ -0,0 +1,111 @@ +//! Editing operations — raise, lower, smooth, flatten, etc. +//! +//! Each operation implements the Operation trait and supports undo. + +use crate::model::dimension::Dimension; + +/// An operation that can be applied and reverted. +pub trait Operation: Send + Sync { + fn name(&self) -> &'static str; + fn apply(&self, dim: &mut Dimension) -> Result<(), OperationError>; + fn inverse(&self) -> Box; +} + +#[derive(Debug)] +pub enum OperationError { + InvalidParameters(String), + OutOfBounds { tx: i32, tz: i32, x: u32, z: u32 }, +} + +mod brush; +mod shaping; +mod fill; + +pub use brush::{FloodOperation, PaintOperation, PencilOperation}; +pub use shaping::{ErodeOperation, FlattenOperation, HeightOperation, MultiTileOperation, SmoothOperation}; +pub use fill::FillOperation; + +/// Restores heights from a saved snapshot (used as inverse of FlattenOperation). +pub struct RestoreHeightsOperation { + pub tile_x: i32, + pub tile_z: i32, + pub snapshot: Vec<(usize, i16)>, +} + +impl Operation for RestoreHeightsOperation { + fn name(&self) -> &'static str { + "RestoreHeights" + } + + fn apply(&self, dim: &mut Dimension) -> Result<(), OperationError> { + let tile = + dim.tiles + .get_mut(&(self.tile_x, self.tile_z)) + .ok_or(OperationError::OutOfBounds { + tx: self.tile_x, + tz: self.tile_z, + x: 0, + z: 0, + })?; + for &(idx, h) in &self.snapshot { + if idx < tile.heightmap.len() { + tile.heightmap[idx] = h; + } + } + Ok(()) + } + + fn inverse(&self) -> Box { + Box::new(NoOpOperation) + } +} + +/// Restores terrain values from a saved snapshot (used as inverse of PaintOperation). +pub struct RestoreTerrainOperation { + pub tile_x: i32, + pub tile_z: i32, + pub snapshot: Vec<(usize, u8)>, +} + +impl Operation for RestoreTerrainOperation { + fn name(&self) -> &'static str { + "RestoreTerrain" + } + + fn apply(&self, dim: &mut Dimension) -> Result<(), OperationError> { + let tile = + dim.tiles + .get_mut(&(self.tile_x, self.tile_z)) + .ok_or(OperationError::OutOfBounds { + tx: self.tile_x, + tz: self.tile_z, + x: 0, + z: 0, + })?; + for &(idx, t) in &self.snapshot { + if idx < tile.terrain.len() { + tile.terrain[idx] = t; + } + } + Ok(()) + } + + fn inverse(&self) -> Box { + Box::new(NoOpOperation) + } +} + +/// No-op operation (used as fallback inverse). +pub struct NoOpOperation; + +impl Operation for NoOpOperation { + fn name(&self) -> &'static str { + "NoOp" + } + fn apply(&self, _dim: &mut Dimension) -> Result<(), OperationError> { + Ok(()) + } + fn inverse(&self) -> Box { + Box::new(NoOpOperation) + } +} diff --git a/core/src/ops/operations/shaping/erode.rs b/core/src/ops/operations/shaping/erode.rs new file mode 100644 index 0000000..0c1f084 --- /dev/null +++ b/core/src/ops/operations/shaping/erode.rs @@ -0,0 +1,152 @@ +use std::sync::Arc; +use std::sync::OnceLock; + +use crate::model::types::Brush; +use crate::model::dimension::Dimension; +use crate::model::tile::TILE_SIZE; + +use crate::ops::operations::{Operation, OperationError, RestoreHeightsOperation}; + +/// Simulate thermal erosion — material moves from high to low when slope exceeds threshold. +pub struct ErodeOperation { + pub tile_x: i32, + pub tile_z: i32, + pub center_x: u32, + pub center_z: u32, + pub radius: u32, + pub iterations: u32, + pub talus_angle: f64, + pub brush: Arc, + pub before_snapshot: OnceLock>, +} + +impl Operation for ErodeOperation { + fn name(&self) -> &'static str { + "Erode" + } + + fn apply(&self, dim: &mut Dimension) -> Result<(), OperationError> { + let tile = + dim.tiles + .get_mut(&(self.tile_x, self.tile_z)) + .ok_or(OperationError::OutOfBounds { + tx: self.tile_x, + tz: self.tile_z, + x: 0, + z: 0, + })?; + + let r = self.radius as i32; + let cx = self.center_x as i32; + let cz = self.center_z as i32; + + // Snapshot original heights on first apply + let mut snapshot: Vec<(usize, i16)> = Vec::new(); + let is_first_apply = self.before_snapshot.get().is_none(); + if is_first_apply { + for dz in -r..=r { + for dx in -r..=r { + let ax = cx + dx; + let az = cz + dz; + if ax < 0 || az < 0 || ax >= TILE_SIZE as i32 || az >= TILE_SIZE as i32 { + continue; + } + if self.brush.get_strength(dx as f64, dz as f64) <= 0.0 { + continue; + } + let idx = (az as usize) * TILE_SIZE + (ax as usize); + snapshot.push((idx, tile.heightmap[idx])); + } + } + } + if is_first_apply { + let _ = self.before_snapshot.set(snapshot); + } + + // Apply erosion for `iterations` passes + let tile_size = TILE_SIZE; + for _ in 0..self.iterations.max(1) { + let mut deltas = vec![0i32; tile_size * tile_size]; + + for dz in -r..=r { + for dx in -r..=r { + let ax = cx + dx; + let az = cz + dz; + if ax < 0 || az < 0 || ax >= tile_size as i32 || az >= tile_size as i32 { + continue; + } + if self.brush.get_strength(dx as f64, dz as f64) <= 0.0 { + continue; + } + + let idx = (az as usize) * tile_size + (ax as usize); + let current = tile.heightmap[idx] as i32; + + // Find lowest neighbor that is also in brush area + let mut lowest_h = current; + let mut lowest_idx = None; + for (ndx, ndz) in [(0, -1), (0, 1), (-1, 0), (1, 0)] { + let bx = ax + ndx; + let bz = az + ndz; + if bx < 0 || bx >= tile_size as i32 || bz < 0 || bz >= tile_size as i32 { + continue; + } + let n_dx = bx - cx; + let n_dz = bz - cz; + if self.brush.get_strength(n_dx as f64, n_dz as f64) <= 0.0 { + continue; + } + let nidx = (bz as usize) * tile_size + (bx as usize); + let nh = tile.heightmap[nidx] as i32; + if nh < lowest_h { + lowest_h = nh; + lowest_idx = Some(nidx); + } + } + + if let Some(lidx) = lowest_idx { + let diff = current - lowest_h; + if (diff as f64) > self.talus_angle { + let excess = diff as f64 - self.talus_angle; + let move_amount = (excess * 0.5).round() as i32; + deltas[idx] -= move_amount; + deltas[lidx] += move_amount; + } + } + } + } + + // Apply deltas + for dz in -r..=r { + for dx in -r..=r { + let ax = cx + dx; + let az = cz + dz; + if ax < 0 || az < 0 || ax >= tile_size as i32 || az >= tile_size as i32 { + continue; + } + if self.brush.get_strength(dx as f64, dz as f64) <= 0.0 { + continue; + } + let idx = (az as usize) * tile_size + (ax as usize); + if deltas[idx] != 0 { + let new_h = (tile.heightmap[idx] as i32 + deltas[idx]) + .clamp(tile.min_height as i32, tile.max_height as i32) + as i16; + tile.heightmap[idx] = new_h; + } + } + } + } + + Ok(()) + } + + fn inverse(&self) -> Box { + let snapshot = self.before_snapshot.get().cloned().unwrap_or_default(); + Box::new(RestoreHeightsOperation { + tile_x: self.tile_x, + tile_z: self.tile_z, + snapshot, + }) + } +} diff --git a/core/src/ops/operations/shaping/height.rs b/core/src/ops/operations/shaping/height.rs new file mode 100644 index 0000000..0955d62 --- /dev/null +++ b/core/src/ops/operations/shaping/height.rs @@ -0,0 +1,196 @@ +use std::sync::Arc; +use std::sync::OnceLock; + +use crate::model::types::Brush; +use crate::model::dimension::Dimension; +use crate::model::tile::TILE_SIZE; + +use crate::ops::operations::{Operation, OperationError, RestoreHeightsOperation}; + +/// Raise or lower terrain height within a brush area. +pub struct HeightOperation { + pub tile_x: i32, + pub tile_z: i32, + pub center_x: u32, + pub center_z: u32, + pub radius: u32, + pub delta: i16, + pub brush: Arc, + pub before_snapshot: OnceLock>, +} + +impl Operation for HeightOperation { + fn name(&self) -> &'static str { + if self.delta >= 0 { "Raise" } else { "Lower" } + } + + fn apply(&self, dim: &mut Dimension) -> Result<(), OperationError> { + let tile = + dim.tiles + .get_mut(&(self.tile_x, self.tile_z)) + .ok_or(OperationError::OutOfBounds { + tx: self.tile_x, + tz: self.tile_z, + x: 0, + z: 0, + })?; + + let r = self.radius as i32; + let cx = self.center_x as i32; + let cz = self.center_z as i32; + + let mut heights: Vec<(usize, i16)> = Vec::new(); + + for dz in -r..=r { + for dx in -r..=r { + let ax = cx + dx; + let az = cz + dz; + if ax < 0 || az < 0 || ax >= TILE_SIZE as i32 || az >= TILE_SIZE as i32 { + continue; + } + let strength = self.brush.get_strength(dx as f64, dz as f64); + if strength <= 0.0 { + continue; + } + + let idx = (az as usize) * TILE_SIZE + (ax as usize); + + if self.before_snapshot.get().is_none() { + heights.push((idx, tile.heightmap[idx])); + } + + let change = (self.delta as f64 * strength).round() as i16; + let new_height = + (tile.heightmap[idx] + change).clamp(tile.min_height, tile.max_height); + tile.heightmap[idx] = new_height; + } + } + + if self.before_snapshot.get().is_none() { + let _ = self.before_snapshot.set(heights); + } + + Ok(()) + } + + fn inverse(&self) -> Box { + let snapshot = self.before_snapshot.get().cloned().unwrap_or_default(); + Box::new(RestoreHeightsOperation { + tile_x: self.tile_x, + tile_z: self.tile_z, + snapshot, + }) + } +} + +/// Flatten terrain to a target height within a brush area. +pub struct FlattenOperation { + pub tile_x: i32, + pub tile_z: i32, + pub center_x: u32, + pub center_z: u32, + pub radius: u32, + pub target_height: i16, + pub brush: Arc, + pub before_snapshot: OnceLock>, +} + +impl Operation for FlattenOperation { + fn name(&self) -> &'static str { + "Flatten" + } + + fn apply(&self, dim: &mut Dimension) -> Result<(), OperationError> { + let tile = + dim.tiles + .get_mut(&(self.tile_x, self.tile_z)) + .ok_or(OperationError::OutOfBounds { + tx: self.tile_x, + tz: self.tile_z, + x: 0, + z: 0, + })?; + + let r = self.radius as i32; + let cx = self.center_x as i32; + let cz = self.center_z as i32; + + let mut heights: Vec<(usize, i16)> = Vec::new(); + + for dz in -r..=r { + for dx in -r..=r { + let ax = cx + dx; + let az = cz + dz; + if ax < 0 || az < 0 || ax >= TILE_SIZE as i32 || az >= TILE_SIZE as i32 { + continue; + } + let strength = self.brush.get_strength(dx as f64, dz as f64); + if strength <= 0.0 { + continue; + } + + let idx = (az as usize) * TILE_SIZE + (ax as usize); + + if self.before_snapshot.get().is_none() { + heights.push((idx, tile.heightmap[idx])); + } + + let current = tile.heightmap[idx]; + let diff = self.target_height as i32 - current as i32; + let change = (diff as f64 * strength).round() as i32; + let new_height = (current as i32 + change) + .clamp(tile.min_height as i32, tile.max_height as i32) + as i16; + tile.heightmap[idx] = new_height; + } + } + + if self.before_snapshot.get().is_none() { + let _ = self.before_snapshot.set(heights); + } + + Ok(()) + } + + fn inverse(&self) -> Box { + let snapshot = self.before_snapshot.get().cloned().unwrap_or_default(); + Box::new(RestoreHeightsOperation { + tile_x: self.tile_x, + tile_z: self.tile_z, + snapshot, + }) + } +} + +/// Applies multiple sub-operations, supporting brushes that span multiple tiles. +pub struct MultiTileOperation { + pub operations: Vec>, +} + +impl Operation for MultiTileOperation { + fn name(&self) -> &'static str { + if self.operations.is_empty() { + "MultiTile (empty)" + } else { + self.operations[0].name() + } + } + + fn apply(&self, dim: &mut Dimension) -> Result<(), OperationError> { + for op in &self.operations { + op.apply(dim)?; + } + Ok(()) + } + + fn inverse(&self) -> Box { + let mut inverses: Vec> = Vec::with_capacity(self.operations.len()); + for op in &self.operations { + inverses.push(op.inverse()); + } + inverses.reverse(); + Box::new(MultiTileOperation { + operations: inverses, + }) + } +} diff --git a/core/src/ops/operations/shaping/mod.rs b/core/src/ops/operations/shaping/mod.rs new file mode 100644 index 0000000..8805bac --- /dev/null +++ b/core/src/ops/operations/shaping/mod.rs @@ -0,0 +1,6 @@ +mod height; +mod smooth; +mod erode; +pub use height::*; +pub use smooth::*; +pub use erode::*; diff --git a/core/src/ops/operations/shaping/smooth.rs b/core/src/ops/operations/shaping/smooth.rs new file mode 100644 index 0000000..707f710 --- /dev/null +++ b/core/src/ops/operations/shaping/smooth.rs @@ -0,0 +1,120 @@ +use std::sync::Arc; +use std::sync::OnceLock; + +use crate::model::types::Brush; +use crate::model::dimension::Dimension; +use crate::model::tile::TILE_SIZE; + +use crate::ops::operations::{Operation, OperationError, RestoreHeightsOperation}; + +/// Smooth terrain by averaging heights in a 3x3 neighborhood. +pub struct SmoothOperation { + pub tile_x: i32, + pub tile_z: i32, + pub center_x: u32, + pub center_z: u32, + pub radius: u32, + pub iterations: u32, + pub brush: Arc, + pub before_snapshot: OnceLock>, +} + +impl Operation for SmoothOperation { + fn name(&self) -> &'static str { + "Smooth" + } + + fn apply(&self, dim: &mut Dimension) -> Result<(), OperationError> { + let tile = + dim.tiles + .get_mut(&(self.tile_x, self.tile_z)) + .ok_or(OperationError::OutOfBounds { + tx: self.tile_x, + tz: self.tile_z, + x: 0, + z: 0, + })?; + + let r = self.radius as i32; + let cx = self.center_x as i32; + let cz = self.center_z as i32; + + // Snapshot original heights + let mut snapshot: Vec<(usize, i16)> = Vec::new(); + for dz in -r..=r { + for dx in -r..=r { + let ax = cx + dx; + let az = cz + dz; + if ax < 0 || az < 0 || ax >= TILE_SIZE as i32 || az >= TILE_SIZE as i32 { + continue; + } + if self.brush.get_strength(dx as f64, dz as f64) <= 0.0 { + continue; + } + if self.before_snapshot.get().is_none() { + snapshot.push(( + (az as usize) * TILE_SIZE + (ax as usize), + tile.heightmap[(az as usize) * TILE_SIZE + (ax as usize)], + )); + } + } + } + if self.before_snapshot.get().is_none() { + let _ = self.before_snapshot.set(snapshot); + } + + // Apply smoothing for `iterations` passes + for _ in 0..self.iterations.max(1) { + let mut new_heights = tile.heightmap; + + for dz in -r..=r { + for dx in -r..=r { + let ax = cx + dx; + let az = cz + dz; + if ax < 0 || az < 0 || ax >= TILE_SIZE as i32 || az >= TILE_SIZE as i32 { + continue; + } + if self.brush.get_strength(dx as f64, dz as f64) <= 0.0 { + continue; + } + + let idx = (az as usize) * TILE_SIZE + (ax as usize); + + // Average of 3x3 neighborhood (clamped to tile bounds) + let mut sum = 0i32; + let mut count = 0i32; + for ny in -1..=1i32 { + for nx in -1..=1i32 { + let bx = ax + nx; + let bz = az + ny; + if bx >= 0 && bx < TILE_SIZE as i32 && bz >= 0 && bz < TILE_SIZE as i32 + { + sum += tile.heightmap[(bz as usize) * TILE_SIZE + (bx as usize)] + as i32; + count += 1; + } + } + } + if count > 0 { + new_heights[idx] = (sum / count) + .clamp(tile.min_height as i32, tile.max_height as i32) + as i16; + } + } + } + + tile.heightmap = new_heights; + } + + Ok(()) + } + + fn inverse(&self) -> Box { + let snapshot = self.before_snapshot.get().cloned().unwrap_or_default(); + Box::new(RestoreHeightsOperation { + tile_x: self.tile_x, + tile_z: self.tile_z, + snapshot, + }) + } +} diff --git a/core/src/plugins/export_plugin.rs b/core/src/plugins/export_plugin.rs deleted file mode 100755 index 6eebbb4..0000000 --- a/core/src/plugins/export_plugin.rs +++ /dev/null @@ -1,11 +0,0 @@ -use crate::model::world::World; -use std::path::Path; - -/// A plugin that provides custom export formats. -pub trait ExportPlugin: Send + Sync { - fn name(&self) -> &'static str; - fn version(&self) -> &'static str; - fn can_export(&self, world: &World) -> bool; - fn export(&self, world: &World, path: &Path) -> Result<(), Box>; - fn format_name(&self) -> &'static str; -} diff --git a/core/src/plugins/layer_plugin.rs b/core/src/plugins/layer_plugin.rs deleted file mode 100755 index c86aeb1..0000000 --- a/core/src/plugins/layer_plugin.rs +++ /dev/null @@ -1,8 +0,0 @@ -use crate::model::layers::Layer; - -/// A plugin that provides custom layers for world painting. -pub trait LayerPlugin: Send + Sync { - fn name(&self) -> &'static str; - fn version(&self) -> &'static str; - fn layers(&self) -> Vec>; -} diff --git a/core/src/plugins/mod.rs b/core/src/plugins/mod.rs index 78d0a18..15a78cb 100755 --- a/core/src/plugins/mod.rs +++ b/core/src/plugins/mod.rs @@ -1,16 +1,7 @@ //! Plugin host — trait-объекты для расширения функциональности. -//! -//! Плагины позволяют добавлять custom слои, операции редактирования, -//! форматы экспорта и источники генерации, не меняя ядро. -pub mod export_plugin; -pub mod layer_plugin; -pub mod operation_plugin; +mod traits; pub mod registry; -pub mod source_plugin; -pub use export_plugin::ExportPlugin; -pub use layer_plugin::LayerPlugin; -pub use operation_plugin::OperationPlugin; +pub use traits::{ExportPlugin, LayerPlugin, OperationPlugin, TileSourcePlugin}; pub use registry::PluginRegistry; -pub use source_plugin::TileSourcePlugin; diff --git a/core/src/plugins/operation_plugin.rs b/core/src/plugins/operation_plugin.rs deleted file mode 100755 index c527d92..0000000 --- a/core/src/plugins/operation_plugin.rs +++ /dev/null @@ -1,8 +0,0 @@ -use crate::ops::operations::Operation; - -/// A plugin that provides custom editing operations. -pub trait OperationPlugin: Send + Sync { - fn name(&self) -> &'static str; - fn version(&self) -> &'static str; - fn operations(&self) -> Vec Box>>; -} diff --git a/core/src/plugins/source_plugin.rs b/core/src/plugins/source_plugin.rs deleted file mode 100755 index bd5ffc4..0000000 --- a/core/src/plugins/source_plugin.rs +++ /dev/null @@ -1,8 +0,0 @@ -use crate::model::tile::Tile; - -/// A plugin that provides custom tile or heightmap generation. -pub trait TileSourcePlugin: Send + Sync { - fn name(&self) -> &'static str; - fn version(&self) -> &'static str; - fn generate_tile(&self, tile: &mut Tile, seed: u64); -} diff --git a/core/src/plugins/traits.rs b/core/src/plugins/traits.rs new file mode 100644 index 0000000..4d08f19 --- /dev/null +++ b/core/src/plugins/traits.rs @@ -0,0 +1,31 @@ +use crate::model::layers::Layer; +use crate::model::tile::Tile; +use crate::model::world::World; +use crate::ops::operations::Operation; +use std::path::Path; + +pub trait ExportPlugin: Send + Sync { + fn name(&self) -> &'static str; + fn version(&self) -> &'static str; + fn can_export(&self, world: &World) -> bool; + fn export(&self, world: &World, path: &Path) -> Result<(), Box>; + fn format_name(&self) -> &'static str; +} + +pub trait LayerPlugin: Send + Sync { + fn name(&self) -> &'static str; + fn version(&self) -> &'static str; + fn layers(&self) -> Vec>; +} + +pub trait OperationPlugin: Send + Sync { + fn name(&self) -> &'static str; + fn version(&self) -> &'static str; + fn operations(&self) -> Vec Box>>; +} + +pub trait TileSourcePlugin: Send + Sync { + fn name(&self) -> &'static str; + fn version(&self) -> &'static str; + fn generate_tile(&self, tile: &mut Tile, seed: u64); +} diff --git a/core/tests/golden_test.rs b/core/tests/golden_test.rs index 13127bc..2a17026 100755 --- a/core/tests/golden_test.rs +++ b/core/tests/golden_test.rs @@ -151,9 +151,11 @@ fn test_golden_mca_roundtrip() { assert_eq!(dim.name, "overworld"); assert_eq!(dim.seed, 42); - // Note: load_save currently has a TODO on line 129 of minecraft.rs — - // parsed chunks are not yet dispatched to tiles, so tiles is always - // empty after loading. We verify chunk parsing via the direct test below. + // Chunks are dispatched to tiles; verify we have data. + assert!(!dim.tiles.is_empty(), "tiles should contain parsed chunks"); + let tile = dim.tiles.get(&(0, 0)).expect("tile (0,0) should exist"); + // Chunk at global (0,0) → tile (0,0), height = 4 * 16 + 15 = 79 + assert_eq!(tile.heightmap[0], 79, "first block height should be 79"); // --- 2. Direct region + chunk parsing verification --- let region = Region::from_bytes(0, 0, ®ion_bytes).expect("region should parse"); diff --git a/crates/fastanvil/src/io/chunk.rs b/crates/fastanvil/src/io/chunk/chunk.rs similarity index 67% rename from crates/fastanvil/src/io/chunk.rs rename to crates/fastanvil/src/io/chunk/chunk.rs index d3bc8c2..6dca550 100755 --- a/crates/fastanvil/src/io/chunk.rs +++ b/crates/fastanvil/src/io/chunk/chunk.rs @@ -1,5 +1,4 @@ //! Chunk data structures for Minecraft Anvil format. - use std::collections::HashMap; /// A parsed chunk from an Anvil (.mca) region file. @@ -27,7 +26,6 @@ pub struct ChunkSection { pub block_light: Option>, pub sky_light: Option>, } - impl Chunk { /// Parse a chunk from an NBT Compound tag. pub fn from_nbt(tag: &terrafier_nbt::Tag) -> Option { @@ -86,37 +84,7 @@ impl Chunk { raw, }) } - - /// Serialize this chunk back to an NBT Compound tag. - pub fn to_nbt(&self) -> terrafier_nbt::Tag { - let mut compound = self.raw.clone(); - - compound.insert("xPos".into(), terrafier_nbt::Tag::Int(self.x)); - compound.insert("zPos".into(), terrafier_nbt::Tag::Int(self.z)); - compound.insert( - "DataVersion".into(), - terrafier_nbt::Tag::Int(self.data_version), - ); - - let sections_list: Vec = - self.sections.iter().map(|s| s.to_nbt()).collect(); - compound.insert("sections".into(), terrafier_nbt::Tag::List(sections_list)); - - if let Some(status) = &self.status { - compound.insert("Status".into(), terrafier_nbt::Tag::String(status.clone())); - } - - if !self.heightmaps.is_empty() { - compound.insert( - "Heightmaps".into(), - terrafier_nbt::Tag::Compound(self.heightmaps.clone()), - ); - } - - terrafier_nbt::Tag::Compound(compound) - } } - impl ChunkSection { /// Parse a section from an NBT Compound tag. pub fn from_nbt(tag: &terrafier_nbt::Tag) -> Option { @@ -216,64 +184,7 @@ impl ChunkSection { sky_light, }) } - - /// Serialize section back to NBT Compound. - pub fn to_nbt(&self) -> terrafier_nbt::Tag { - let mut compound = HashMap::new(); - compound.insert("Y".into(), terrafier_nbt::Tag::Byte(self.section_y)); - - // Block states - let palette_list: Vec = self - .palette - .iter() - .map(|p| terrafier_nbt::Tag::Compound(p.clone())) - .collect(); - let mut block_states = HashMap::new(); - block_states.insert("palette".into(), terrafier_nbt::Tag::List(palette_list)); - if !self.block_data.is_empty() { - block_states.insert( - "data".into(), - terrafier_nbt::Tag::LongArray(self.block_data.clone()), - ); - } - compound.insert( - "block_states".into(), - terrafier_nbt::Tag::Compound(block_states), - ); - - // Biomes - let biome_palette_list: Vec = self - .biome_palette - .iter() - .map(|p| terrafier_nbt::Tag::Compound(p.clone())) - .collect(); - let mut biomes = HashMap::new(); - biomes.insert( - "palette".into(), - terrafier_nbt::Tag::List(biome_palette_list), - ); - if !self.biome_data.is_empty() { - biomes.insert( - "data".into(), - terrafier_nbt::Tag::LongArray(self.biome_data.clone()), - ); - } - compound.insert("biomes".into(), terrafier_nbt::Tag::Compound(biomes)); - - if let Some(bl) = &self.block_light { - compound.insert( - "BlockLight".into(), - terrafier_nbt::Tag::ByteArray(bl.clone()), - ); - } - if let Some(sl) = &self.sky_light { - compound.insert("SkyLight".into(), terrafier_nbt::Tag::ByteArray(sl.clone())); - } - - terrafier_nbt::Tag::Compound(compound) - } } - fn get_int(map: &HashMap, key: &str) -> Option { map.get(key).and_then(|t| match t { terrafier_nbt::Tag::Int(v) => Some(*v), diff --git a/crates/fastanvil/src/io/chunk/chunk_write.rs b/crates/fastanvil/src/io/chunk/chunk_write.rs new file mode 100644 index 0000000..852b9f1 --- /dev/null +++ b/crates/fastanvil/src/io/chunk/chunk_write.rs @@ -0,0 +1,90 @@ +use std::collections::HashMap; + +use super::chunk::{Chunk, ChunkSection}; + +impl Chunk { + /// Serialize this chunk back to an NBT Compound tag. + pub fn to_nbt(&self) -> terrafier_nbt::Tag { + let mut compound = self.raw.clone(); + + compound.insert("xPos".into(), terrafier_nbt::Tag::Int(self.x)); + compound.insert("zPos".into(), terrafier_nbt::Tag::Int(self.z)); + compound.insert( + "DataVersion".into(), + terrafier_nbt::Tag::Int(self.data_version), + ); + + let sections_list: Vec = + self.sections.iter().map(|s| s.to_nbt()).collect(); + compound.insert("sections".into(), terrafier_nbt::Tag::List(sections_list)); + + if let Some(status) = &self.status { + compound.insert("Status".into(), terrafier_nbt::Tag::String(status.clone())); + } + + if !self.heightmaps.is_empty() { + compound.insert( + "Heightmaps".into(), + terrafier_nbt::Tag::Compound(self.heightmaps.clone()), + ); + } + + terrafier_nbt::Tag::Compound(compound) + } +} + +impl ChunkSection { + /// Serialize section back to NBT Compound. + pub fn to_nbt(&self) -> terrafier_nbt::Tag { + let mut compound = HashMap::new(); + compound.insert("Y".into(), terrafier_nbt::Tag::Byte(self.section_y)); + + let palette_list: Vec = self + .palette + .iter() + .map(|p| terrafier_nbt::Tag::Compound(p.clone())) + .collect(); + let mut block_states = HashMap::new(); + block_states.insert("palette".into(), terrafier_nbt::Tag::List(palette_list)); + if !self.block_data.is_empty() { + block_states.insert( + "data".into(), + terrafier_nbt::Tag::LongArray(self.block_data.clone()), + ); + } + compound.insert( + "block_states".into(), + terrafier_nbt::Tag::Compound(block_states), + ); + + let biome_palette_list: Vec = self + .biome_palette + .iter() + .map(|p| terrafier_nbt::Tag::Compound(p.clone())) + .collect(); + let mut biomes = HashMap::new(); + biomes.insert( + "palette".into(), + terrafier_nbt::Tag::List(biome_palette_list), + ); + if !self.biome_data.is_empty() { + biomes.insert( + "data".into(), + terrafier_nbt::Tag::LongArray(self.biome_data.clone()), + ); + } + compound.insert("biomes".into(), terrafier_nbt::Tag::Compound(biomes)); + + if let Some(bl) = &self.block_light { + compound.insert( + "BlockLight".into(), + terrafier_nbt::Tag::ByteArray(bl.clone()), + ); + } + if let Some(sl) = &self.sky_light { + compound.insert("SkyLight".into(), terrafier_nbt::Tag::ByteArray(sl.clone())); + } + + terrafier_nbt::Tag::Compound(compound) + } +} diff --git a/crates/fastanvil/src/io/chunk/mod.rs b/crates/fastanvil/src/io/chunk/mod.rs new file mode 100644 index 0000000..02d123a --- /dev/null +++ b/crates/fastanvil/src/io/chunk/mod.rs @@ -0,0 +1,4 @@ +mod chunk; +mod chunk_write; + +pub use chunk::*; diff --git a/crates/fastanvil/src/io/mod.rs b/crates/fastanvil/src/io/mod.rs index a0963ee..9c65ba8 100755 --- a/crates/fastanvil/src/io/mod.rs +++ b/crates/fastanvil/src/io/mod.rs @@ -1,4 +1,2 @@ -//! Anvil format I/O — region file and chunk data structures. - pub mod chunk; pub mod region; diff --git a/crates/fastanvil/src/io/region/mod.rs b/crates/fastanvil/src/io/region/mod.rs new file mode 100644 index 0000000..fc92064 --- /dev/null +++ b/crates/fastanvil/src/io/region/mod.rs @@ -0,0 +1,4 @@ +mod region; +mod region_write; + +pub use region::*; diff --git a/crates/fastanvil/src/io/region.rs b/crates/fastanvil/src/io/region/region.rs similarity index 68% rename from crates/fastanvil/src/io/region.rs rename to crates/fastanvil/src/io/region/region.rs index eb003e8..2113e7c 100755 --- a/crates/fastanvil/src/io/region.rs +++ b/crates/fastanvil/src/io/region/region.rs @@ -28,7 +28,7 @@ pub type Result = std::result::Result; pub struct Region { pub x: i32, pub z: i32, - chunks: HashMap<(u8, u8), ChunkEntry>, + pub(crate) chunks: HashMap<(u8, u8), ChunkEntry>, } pub struct ChunkEntry { @@ -163,68 +163,5 @@ impl Region { self.chunks.len() } - /// Serialize region back to .mca bytes. - pub fn to_bytes(&self) -> Result> { - let sector_size: u64 = 4096; - let mut locations = [0u32; 1024]; - let mut timestamps = [0u32; 1024]; - let mut sector_data: Vec> = Vec::new(); - for i in 0..1024 { - let local_x = (i % 32) as u8; - let local_z = (i / 32) as u8; - - if let Some(entry) = self.chunks.get(&(local_x, local_z)) { - timestamps[i] = entry.timestamp; - - // Serialize NBT and compress with Zlib - let compressed = compression::compress( - entry.data.as_deref().unwrap_or_default(), - CompressionType::Zlib, - )?; - - // Prepend: length (4 bytes BE) + compression type (1 byte) - let total_len = 1 + compressed.len(); - let mut sector = Vec::with_capacity(4 + total_len); - sector.extend(&(total_len as u32).to_be_bytes()); - sector.push(CompressionType::Zlib.id()); - sector.extend(&compressed); - - // Pad to sector boundary - while sector.len() % sector_size as usize != 0 { - sector.push(0); - } - - let offset = 2 + sector_data.len() as u32; - locations[i] = - (offset << 8) | ((sector.len() / sector_size as usize) as u32 & 0xFF); - sector_data.push(sector); - } - } - - // Build output: header + sector data - let mut output = - Vec::with_capacity(2 * 4096 + sector_data.iter().map(|s| s.len()).sum::()); - - // Location table - for loc in locations.iter() { - output.extend(&loc.to_be_bytes()); - } - // Timestamp table - for ts in timestamps.iter() { - output.extend(&ts.to_be_bytes()); - } - - // Pad header to exactly 2 sectors - while output.len() < 2 * sector_size as usize { - output.push(0); - } - - // Sector data - for sector in §or_data { - output.extend(sector); - } - - Ok(output) - } } diff --git a/crates/fastanvil/src/io/region/region_write.rs b/crates/fastanvil/src/io/region/region_write.rs new file mode 100644 index 0000000..326dc1c --- /dev/null +++ b/crates/fastanvil/src/io/region/region_write.rs @@ -0,0 +1,62 @@ +use crate::compression::{self, CompressionType}; + +use super::region::{Region, Result}; + +impl Region { + /// Serialize region back to .mca bytes. + pub fn to_bytes(&self) -> Result> { + let sector_size: u64 = 4096; + let mut locations = [0u32; 1024]; + let mut timestamps = [0u32; 1024]; + let mut sector_data: Vec> = Vec::new(); + + for i in 0..1024 { + let local_x = (i % 32) as u8; + let local_z = (i / 32) as u8; + + if let Some(entry) = self.chunks.get(&(local_x, local_z)) { + timestamps[i] = entry.timestamp; + + let compressed = compression::compress( + entry.data.as_deref().unwrap_or_default(), + CompressionType::Zlib, + )?; + + let total_len = 1 + compressed.len(); + let mut sector = Vec::with_capacity(4 + total_len); + sector.extend(&(total_len as u32).to_be_bytes()); + sector.push(CompressionType::Zlib.id()); + sector.extend(&compressed); + + while sector.len() % sector_size as usize != 0 { + sector.push(0); + } + + let offset = 2 + sector_data.len() as u32; + locations[i] = + (offset << 8) | ((sector.len() / sector_size as usize) as u32 & 0xFF); + sector_data.push(sector); + } + } + + let mut output = + Vec::with_capacity(2 * 4096 + sector_data.iter().map(|s| s.len()).sum::()); + + for loc in locations.iter() { + output.extend(&loc.to_be_bytes()); + } + for ts in timestamps.iter() { + output.extend(&ts.to_be_bytes()); + } + + while output.len() < 2 * sector_size as usize { + output.push(0); + } + + for sector in §or_data { + output.extend(sector); + } + + Ok(output) + } +} diff --git a/crates/nbt/src/io/mod.rs b/crates/nbt/src/io/mod.rs index b3af7b0..d37775b 100755 --- a/crates/nbt/src/io/mod.rs +++ b/crates/nbt/src/io/mod.rs @@ -1,4 +1,9 @@ //! NBT I/O — binary reader and writer for Java Edition (Big Endian). -pub mod reader; -pub mod writer; +pub mod read; +pub mod write; + +pub use read::reader; +pub use read::read_tag; +pub use write::writer; +pub use write::write_tag; diff --git a/crates/nbt/src/io/read/mod.rs b/crates/nbt/src/io/read/mod.rs new file mode 100644 index 0000000..ce231fc --- /dev/null +++ b/crates/nbt/src/io/read/mod.rs @@ -0,0 +1,2 @@ +pub mod reader; +pub mod read_tag; diff --git a/crates/nbt/src/io/reader.rs b/crates/nbt/src/io/read/read_tag.rs old mode 100755 new mode 100644 similarity index 65% rename from crates/nbt/src/io/reader.rs rename to crates/nbt/src/io/read/read_tag.rs index 53e9ed3..2df539f --- a/crates/nbt/src/io/reader.rs +++ b/crates/nbt/src/io/read/read_tag.rs @@ -1,100 +1,11 @@ -//! NBT binary reader for Java Edition (Big Endian). - use std::collections::HashMap; -use std::io::{Cursor, Read}; -use thiserror::Error; +use std::io::Read; use crate::tag::Tag; - -#[derive(Error, Debug)] -pub enum ReadError { - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - #[error("Unknown tag type: {0}")] - UnknownTagType(u8), - #[error("Invalid string length: {0}")] - InvalidStringLength(usize), - #[error("Invalid array length: {0}")] - InvalidArrayLength(usize), -} - -pub type Result = std::result::Result; - -pub fn read_bytes(data: &[u8]) -> Result { - let cursor = Cursor::new(data); - let mut de = NbtReader::new(cursor); - de.read_tag_compound_root() -} - -pub fn read_gzip(data: &[u8]) -> Result { - let mut dec = flate2::read::GzDecoder::new(data); - let mut buf = Vec::new(); - dec.read_to_end(&mut buf)?; - read_bytes(&buf) -} - -struct NbtReader { - inner: R, - buf: Vec, -} +use super::reader::{NbtReader, ReadError, Result}; impl NbtReader { - fn new(inner: R) -> Self { - Self { - inner, - buf: Vec::new(), - } - } - - fn read_exact(&mut self, len: usize) -> Result<&[u8]> { - self.buf.clear(); - self.buf.resize(len, 0); - self.inner.read_exact(&mut self.buf)?; - Ok(&self.buf) - } - - fn read_u8(&mut self) -> Result { - let mut byte = [0u8; 1]; - self.inner.read_exact(&mut byte)?; - Ok(byte[0]) - } - - fn read_i16_be(&mut self) -> Result { - let b = self.read_exact(2)?; - Ok(i16::from_be_bytes([b[0], b[1]])) - } - - fn read_i32_be(&mut self) -> Result { - let b = self.read_exact(4)?; - Ok(i32::from_be_bytes([b[0], b[1], b[2], b[3]])) - } - - fn read_i64_be(&mut self) -> Result { - let b = self.read_exact(8)?; - Ok(i64::from_be_bytes([ - b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], - ])) - } - - fn read_f32_be(&mut self) -> Result { - let b = self.read_exact(4)?; - Ok(f32::from_be_bytes([b[0], b[1], b[2], b[3]])) - } - - fn read_f64_be(&mut self) -> Result { - let b = self.read_exact(8)?; - Ok(f64::from_be_bytes([ - b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], - ])) - } - - fn read_string(&mut self) -> Result { - let len = self.read_i16_be()? as u16 as usize; - let bytes = self.read_exact(len)?.to_vec(); - String::from_utf8(bytes).map_err(|_| ReadError::InvalidStringLength(len)) - } - - fn read_tag_payload(&mut self, tag_type: u8) -> Result { + pub(crate) fn read_tag_payload(&mut self, tag_type: u8) -> Result { match tag_type { 0 => Ok(Tag::End), 1 => Ok(Tag::Byte(self.read_u8()? as i8)), @@ -151,7 +62,7 @@ impl NbtReader { } } - fn read_tag_compound_root(&mut self) -> Result { + pub(crate) fn read_tag_compound_root(&mut self) -> Result { let t = self.read_u8()?; if t == 0 { return Ok(Tag::Compound(HashMap::new())); @@ -166,7 +77,9 @@ impl NbtReader { #[cfg(test)] mod tests { - use super::*; + use super::super::reader::read_bytes; + use crate::tag::Tag; + use std::collections::HashMap; fn root_envelope(data: &[u8]) -> Vec { let mut buf = vec![0x09, 0x00, 0x00]; diff --git a/crates/nbt/src/io/read/reader.rs b/crates/nbt/src/io/read/reader.rs new file mode 100755 index 0000000..17d7854 --- /dev/null +++ b/crates/nbt/src/io/read/reader.rs @@ -0,0 +1,95 @@ +//! NBT binary reader for Java Edition (Big Endian). + +use std::io::{Cursor, Read}; +use thiserror::Error; + +use crate::tag::Tag; + +#[derive(Error, Debug)] +pub enum ReadError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("Unknown tag type: {0}")] + UnknownTagType(u8), + #[error("Invalid string length: {0}")] + InvalidStringLength(usize), + #[error("Invalid array length: {0}")] + InvalidArrayLength(usize), +} + +pub type Result = std::result::Result; + +pub fn read_bytes(data: &[u8]) -> Result { + let cursor = Cursor::new(data); + let mut de = NbtReader::new(cursor); + de.read_tag_compound_root() +} + +pub fn read_gzip(data: &[u8]) -> Result { + let mut dec = flate2::read::GzDecoder::new(data); + let mut buf = Vec::new(); + dec.read_to_end(&mut buf)?; + read_bytes(&buf) +} + +pub(crate) struct NbtReader { + pub(crate) inner: R, + pub(crate) buf: Vec, +} + +impl NbtReader { + pub(crate) fn new(inner: R) -> Self { + Self { + inner, + buf: Vec::new(), + } + } + + pub(crate) fn read_exact(&mut self, len: usize) -> Result<&[u8]> { + self.buf.clear(); + self.buf.resize(len, 0); + self.inner.read_exact(&mut self.buf)?; + Ok(&self.buf) + } + + pub(crate) fn read_u8(&mut self) -> Result { + let mut byte = [0u8; 1]; + self.inner.read_exact(&mut byte)?; + Ok(byte[0]) + } + + pub(crate) fn read_i16_be(&mut self) -> Result { + let b = self.read_exact(2)?; + Ok(i16::from_be_bytes([b[0], b[1]])) + } + + pub(crate) fn read_i32_be(&mut self) -> Result { + let b = self.read_exact(4)?; + Ok(i32::from_be_bytes([b[0], b[1], b[2], b[3]])) + } + + pub(crate) fn read_i64_be(&mut self) -> Result { + let b = self.read_exact(8)?; + Ok(i64::from_be_bytes([ + b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], + ])) + } + + pub(crate) fn read_f32_be(&mut self) -> Result { + let b = self.read_exact(4)?; + Ok(f32::from_be_bytes([b[0], b[1], b[2], b[3]])) + } + + pub(crate) fn read_f64_be(&mut self) -> Result { + let b = self.read_exact(8)?; + Ok(f64::from_be_bytes([ + b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], + ])) + } + + pub(crate) fn read_string(&mut self) -> Result { + let len = self.read_i16_be()? as u16 as usize; + let bytes = self.read_exact(len)?.to_vec(); + String::from_utf8(bytes).map_err(|_| ReadError::InvalidStringLength(len)) + } +} diff --git a/crates/nbt/src/io/write/mod.rs b/crates/nbt/src/io/write/mod.rs new file mode 100644 index 0000000..acefc91 --- /dev/null +++ b/crates/nbt/src/io/write/mod.rs @@ -0,0 +1,2 @@ +pub mod writer; +pub mod write_tag; diff --git a/crates/nbt/src/io/writer.rs b/crates/nbt/src/io/write/write_tag.rs old mode 100755 new mode 100644 similarity index 62% rename from crates/nbt/src/io/writer.rs rename to crates/nbt/src/io/write/write_tag.rs index 0fa754f..cc4c2ee --- a/crates/nbt/src/io/writer.rs +++ b/crates/nbt/src/io/write/write_tag.rs @@ -1,91 +1,10 @@ -//! NBT binary writer for Java Edition (Big Endian). - use std::io::Write; -use thiserror::Error; use crate::tag::Tag; - -#[derive(Error, Debug)] -pub enum WriteError { - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - #[error("Unsupported tag type in list: {0}")] - UnsupportedListType(u8), - #[error("Empty list cannot determine element type")] - EmptyList, -} - -pub type Result = std::result::Result; - -/// Serialize a Tag tree to bytes (Big Endian, no compression). -pub fn to_bytes(tag: &Tag) -> Result> { - let mut buf = Vec::new(); - let mut w = NbtWriter::new(&mut buf); - w.write_tag_compound_root(tag)?; - Ok(buf) -} - -/// Serialize a Tag tree to gzip-compressed bytes. -pub fn to_gzip_bytes(tag: &Tag) -> Result> { - let raw = to_bytes(tag)?; - let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); - encoder.write_all(&raw)?; - Ok(encoder.finish()?) -} - -struct NbtWriter { - inner: W, -} +use super::writer::{NbtWriter, WriteError, Result}; impl NbtWriter { - fn new(inner: W) -> Self { - Self { inner } - } - - fn write_u8(&mut self, val: u8) -> Result<()> { - self.inner.write_all(&[val])?; - Ok(()) - } - - fn write_i16_be(&mut self, val: i16) -> Result<()> { - self.inner.write_all(&val.to_be_bytes())?; - Ok(()) - } - - fn write_i32_be(&mut self, val: i32) -> Result<()> { - self.inner.write_all(&val.to_be_bytes())?; - Ok(()) - } - - fn write_i64_be(&mut self, val: i64) -> Result<()> { - self.inner.write_all(&val.to_be_bytes())?; - Ok(()) - } - - fn write_f32_be(&mut self, val: f32) -> Result<()> { - self.inner.write_all(&val.to_be_bytes())?; - Ok(()) - } - - fn write_f64_be(&mut self, val: f64) -> Result<()> { - self.inner.write_all(&val.to_be_bytes())?; - Ok(()) - } - - fn write_string(&mut self, s: &str) -> Result<()> { - let bytes = s.as_bytes(); - if bytes.len() > u16::MAX as usize { - return Err(WriteError::Io(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "String too long for NBT", - ))); - } - self.write_i16_be(bytes.len() as i16)?; - self.inner.write_all(bytes)?; - Ok(()) - } - - fn write_tag(&mut self, tag: &Tag, name: Option<&str>) -> Result<()> { + pub(crate) fn write_tag(&mut self, tag: &Tag, name: Option<&str>) -> Result<()> { self.write_u8(tag.id())?; if let Some(n) = name { self.write_string(n)?; @@ -94,7 +13,7 @@ impl NbtWriter { Ok(()) } - fn write_tag_payload(&mut self, tag: &Tag) -> Result<()> { + pub(crate) fn write_tag_payload(&mut self, tag: &Tag) -> Result<()> { match tag { Tag::End => {} Tag::Byte(v) => self.write_u8(*v as u8)?, @@ -106,7 +25,7 @@ impl NbtWriter { Tag::String(v) => self.write_string(v)?, Tag::List(items) => { if items.is_empty() { - self.write_u8(1)?; // TAG_Byte as fallback + self.write_u8(1)?; self.write_i32_be(0)?; } else { let elem_type = items[0].id(); @@ -125,7 +44,7 @@ impl NbtWriter { self.write_tag(val, Some(key))?; } } - self.write_u8(0)?; // TAG_End + self.write_u8(0)?; } Tag::ByteArray(v) => { self.write_i32_be(v.len() as i32)?; @@ -149,11 +68,11 @@ impl NbtWriter { Ok(()) } - fn write_tag_compound_root(&mut self, tag: &Tag) -> Result<()> { + pub(crate) fn write_tag_compound_root(&mut self, tag: &Tag) -> Result<()> { match tag { Tag::Compound(map) => { - self.write_u8(9)?; // TAG_Compound - self.write_string("")?; // empty root name + self.write_u8(9)?; + self.write_string("")?; let mut keys: Vec<&String> = map.keys().collect(); keys.sort(); for key in keys { @@ -161,7 +80,7 @@ impl NbtWriter { self.write_tag(val, Some(key))?; } } - self.write_u8(0)?; // TAG_End + self.write_u8(0)?; Ok(()) } _ => Err(WriteError::Io(std::io::Error::new( @@ -174,8 +93,10 @@ impl NbtWriter { #[cfg(test)] mod tests { - use super::*; + use super::super::writer::to_bytes; + use super::super::writer::to_gzip_bytes; use crate::io::reader; + use crate::tag::Tag; use std::collections::HashMap; #[test] diff --git a/crates/nbt/src/io/write/writer.rs b/crates/nbt/src/io/write/writer.rs new file mode 100755 index 0000000..0adb18e --- /dev/null +++ b/crates/nbt/src/io/write/writer.rs @@ -0,0 +1,87 @@ +//! NBT binary writer for Java Edition (Big Endian). + +use std::io::Write; +use thiserror::Error; + +use crate::tag::Tag; + +#[derive(Error, Debug)] +pub enum WriteError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("Unsupported tag type in list: {0}")] + UnsupportedListType(u8), + #[error("Empty list cannot determine element type")] + EmptyList, +} + +pub type Result = std::result::Result; + +/// Serialize a Tag tree to bytes (Big Endian, no compression). +pub fn to_bytes(tag: &Tag) -> Result> { + let mut buf = Vec::new(); + let mut w = NbtWriter::new(&mut buf); + w.write_tag_compound_root(tag)?; + Ok(buf) +} + +/// Serialize a Tag tree to gzip-compressed bytes. +pub fn to_gzip_bytes(tag: &Tag) -> Result> { + let raw = to_bytes(tag)?; + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(&raw)?; + Ok(encoder.finish()?) +} + +pub(crate) struct NbtWriter { + pub(crate) inner: W, +} + +impl NbtWriter { + pub(crate) fn new(inner: W) -> Self { + Self { inner } + } + + pub(crate) fn write_u8(&mut self, val: u8) -> Result<()> { + self.inner.write_all(&[val])?; + Ok(()) + } + + pub(crate) fn write_i16_be(&mut self, val: i16) -> Result<()> { + self.inner.write_all(&val.to_be_bytes())?; + Ok(()) + } + + pub(crate) fn write_i32_be(&mut self, val: i32) -> Result<()> { + self.inner.write_all(&val.to_be_bytes())?; + Ok(()) + } + + pub(crate) fn write_i64_be(&mut self, val: i64) -> Result<()> { + self.inner.write_all(&val.to_be_bytes())?; + Ok(()) + } + + pub(crate) fn write_f32_be(&mut self, val: f32) -> Result<()> { + self.inner.write_all(&val.to_be_bytes())?; + Ok(()) + } + + pub(crate) fn write_f64_be(&mut self, val: f64) -> Result<()> { + self.inner.write_all(&val.to_be_bytes())?; + Ok(()) + } + + pub(crate) fn write_string(&mut self, s: &str) -> Result<()> { + let bytes = s.as_bytes(); + if bytes.len() > u16::MAX as usize { + return Err(WriteError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "String too long for NBT", + ))); + } + self.write_i16_be(bytes.len() as i16)?; + self.inner.write_all(bytes)?; + Ok(()) + } +} diff --git a/crates/palette-compress/src/palette/mod.rs b/crates/palette-compress/src/palette/mod.rs new file mode 100644 index 0000000..b45dad2 --- /dev/null +++ b/crates/palette-compress/src/palette/mod.rs @@ -0,0 +1,4 @@ +mod palette; +mod palette_io; + +pub use palette::*; diff --git a/crates/palette-compress/src/palette/palette.rs b/crates/palette-compress/src/palette/palette.rs new file mode 100755 index 0000000..fd2229d --- /dev/null +++ b/crates/palette-compress/src/palette/palette.rs @@ -0,0 +1,82 @@ +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BlockState { + pub name: String, + pub properties: HashMap, +} + +impl Hash for BlockState { + fn hash(&self, state: &mut H) { + self.name.hash(state); + let mut keys: Vec<&String> = self.properties.keys().collect(); + keys.sort(); + for k in keys { + k.hash(state); + self.properties[k].hash(state); + } + } +} + +impl BlockState { + pub fn new(name: &str) -> Self { + BlockState { + name: name.to_string(), + properties: HashMap::new(), + } + } + + pub fn with_property(mut self, key: &str, val: &str) -> Self { + self.properties.insert(key.to_string(), val.to_string()); + self + } +} + +/// A palette mapping unique `BlockState`s to compact indices. +pub struct BlockPalette { + pub entries: Vec, + index_map: HashMap, +} + +impl Default for BlockPalette { + fn default() -> Self { + Self::new() + } +} + +impl BlockPalette { + pub fn new() -> Self { + BlockPalette { + entries: Vec::new(), + index_map: HashMap::new(), + } + } + + /// Add a block state, or return its existing index. + pub fn add_or_get(&mut self, state: BlockState) -> u32 { + if let Some(&idx) = self.index_map.get(&state) { + return idx; + } + let idx = self.entries.len() as u32; + self.index_map.insert(state.clone(), idx); + self.entries.push(state); + idx + } + + pub fn get(&self, index: u32) -> Option<&BlockState> { + self.entries.get(index as usize) + } + + pub fn len(&self) -> usize { + self.entries.len() + } + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Minimum bits per entry needed for this palette. + pub fn bits_per_entry(&self) -> u8 { + crate::bits::BitArray::bits_needed(self.entries.len()) + } +} diff --git a/crates/palette-compress/src/palette.rs b/crates/palette-compress/src/palette/palette_io.rs old mode 100755 new mode 100644 similarity index 67% rename from crates/palette-compress/src/palette.rs rename to crates/palette-compress/src/palette/palette_io.rs index 9329f35..16f3aca --- a/crates/palette-compress/src/palette.rs +++ b/crates/palette-compress/src/palette/palette_io.rs @@ -1,39 +1,9 @@ use std::collections::HashMap; -use std::hash::{Hash, Hasher}; use terrafier_nbt::Tag; -/// A Minecraft block state — name plus key-value properties. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BlockState { - pub name: String, - pub properties: HashMap, -} - -impl Hash for BlockState { - fn hash(&self, state: &mut H) { - self.name.hash(state); - let mut keys: Vec<&String> = self.properties.keys().collect(); - keys.sort(); - for k in keys { - k.hash(state); - self.properties[k].hash(state); - } - } -} +use super::palette::{BlockPalette, BlockState}; impl BlockState { - pub fn new(name: &str) -> Self { - BlockState { - name: name.to_string(), - properties: HashMap::new(), - } - } - - pub fn with_property(mut self, key: &str, val: &str) -> Self { - self.properties.insert(key.to_string(), val.to_string()); - self - } - /// Parse from `TAG_Compound` with `"Name"` and optional `"Properties"`. pub fn from_nbt(tag: &Tag) -> Option { let map = match tag { @@ -78,53 +48,7 @@ impl BlockState { } } -/// A palette mapping unique `BlockState`s to compact indices. -pub struct BlockPalette { - pub entries: Vec, - index_map: HashMap, -} - -impl Default for BlockPalette { - fn default() -> Self { - Self::new() - } -} - impl BlockPalette { - pub fn new() -> Self { - BlockPalette { - entries: Vec::new(), - index_map: HashMap::new(), - } - } - - /// Add a block state, or return its existing index. - pub fn add_or_get(&mut self, state: BlockState) -> u32 { - if let Some(&idx) = self.index_map.get(&state) { - return idx; - } - let idx = self.entries.len() as u32; - self.index_map.insert(state.clone(), idx); - self.entries.push(state); - idx - } - - pub fn get(&self, index: u32) -> Option<&BlockState> { - self.entries.get(index as usize) - } - - pub fn len(&self) -> usize { - self.entries.len() - } - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Minimum bits per entry needed for this palette. - pub fn bits_per_entry(&self) -> u8 { - crate::bits::BitArray::bits_needed(self.entries.len()) - } - /// Import from NBT palette list + block data `LongArray`. pub fn from_nbt(palette_list: &[Tag], data: &[i64]) -> (Self, crate::bits::BitArray) { let mut palette = BlockPalette::new(); @@ -149,7 +73,8 @@ impl BlockPalette { #[cfg(test)] mod tests { - use super::*; + use super::super::palette::{BlockPalette, BlockState}; + use terrafier_nbt::Tag; #[test] fn test_add_get() { diff --git a/gui/Cargo.toml b/gui/Cargo.toml index 8299a4f..4cf9223 100755 --- a/gui/Cargo.toml +++ b/gui/Cargo.toml @@ -12,5 +12,7 @@ log.workspace = true env_logger.workspace = true egui = "0.31" -eframe = "0.31" +eframe = { version = "0.31", features = ["wgpu"] } +egui-wgpu = "0.31" +wgpu = { version = "24", features = ["vulkan-portability", "webgl"] } rfd = "0.15" diff --git a/gui/src/app.rs b/gui/src/app.rs deleted file mode 100755 index 29b5d80..0000000 --- a/gui/src/app.rs +++ /dev/null @@ -1,185 +0,0 @@ -use std::collections::VecDeque; - -use terrafier_core::World; -use terrafier_core::model::terrain::Terrain; -use terrafier_core::ops::operations::Operation; - -#[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, - 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>, - pub redo_stack: Vec>, - pub brush_local_x: Option, - pub brush_local_z: Option, - pub show_heightmap: bool, - 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, - 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) { - 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() - && 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); - } -} diff --git a/gui/src/app/mod.rs b/gui/src/app/mod.rs new file mode 100644 index 0000000..3c0f01c --- /dev/null +++ b/gui/src/app/mod.rs @@ -0,0 +1,132 @@ +use std::collections::VecDeque; + +use terrafier_core::World; +use terrafier_core::model::types::Terrain; +use terrafier_core::ops::operations::Operation; + +use crate::renderer::GpuRenderer; + +mod ops; +mod update; + +pub enum GpuStatus { + Enabled, + FallbackCpu(&'static str), +} + +#[derive(Clone, Copy, PartialEq)] +pub enum ToolMode { + Raise, + Lower, + Smooth, + Flatten, + Paint, + Erode, + Fill, + 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::Erode => "Erode", + ToolMode::Fill => "Fill", + ToolMode::Inspect => "Inspect", + } + } +} + +pub const LAYER_CAVES_IDX: usize = 0; +pub const LAYER_RIVER_IDX: usize = 1; +pub const LAYER_FROST_IDX: usize = 2; +pub const LAYER_TREES_IDX: usize = 3; +pub const LAYER_BIOME_IDX: usize = 4; +pub const LAYER_RESOURCES_IDX: usize = 5; +pub const LAYER_NAMES: [&str; 6] = ["Caves", "River", "Frost", "Trees", "Biome", "Resources"]; + +pub struct TerrafierApp { + pub world: Option, + 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 erode_iterations: u32, + pub erode_talus_angle: f64, + pub undo_stack: VecDeque>, + pub redo_stack: Vec>, + pub brush_local_x: Option, + pub brush_local_z: Option, + pub show_heightmap: bool, + 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, + pub renderer: Option, + pub show_gpu_render: bool, + pub gpu_status: GpuStatus, + pub cpu_texture_handle: Option, + pub layer_visible: [bool; 6], +} + +impl TerrafierApp { + pub fn new(cc: &eframe::CreationContext) -> Self { + let (renderer, gpu_status) = if let Some(render_state) = &cc.wgpu_render_state { + let device: std::sync::Arc = render_state.device.clone().into(); + let queue: std::sync::Arc = render_state.queue.clone().into(); + let adapter: std::sync::Arc = render_state.adapter.clone().into(); + let mut gpu_renderer = GpuRenderer::new( + device, + queue, + &adapter, + 128, + 128, + include_str!("../renderer/shaders.wgsl"), + ); + let mut renderer_lock = render_state.renderer.write(); + gpu_renderer.register_texture(&mut renderer_lock); + (Some(gpu_renderer), GpuStatus::Enabled) + } else { + (None, GpuStatus::FallbackCpu("GPU init failed — no wgpu adapter found")) + }; + + 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, + erode_iterations: 3, + erode_talus_angle: 0.5, + undo_stack: VecDeque::new(), + redo_stack: Vec::new(), + brush_local_x: None, + brush_local_z: None, + show_heightmap: false, + show_new_world: false, + show_export: false, + status_message: "Ready".to_string(), + world_name: String::new(), + world_seed: String::new(), + export_path: String::new(), + renderer, + show_gpu_render: true, + gpu_status, + cpu_texture_handle: None, + layer_visible: [false; 6], + } + } + +} diff --git a/gui/src/app/ops.rs b/gui/src/app/ops.rs new file mode 100644 index 0000000..561f759 --- /dev/null +++ b/gui/src/app/ops.rs @@ -0,0 +1,48 @@ +use terrafier_core::ops::operations::Operation; + +use crate::app::TerrafierApp; + +impl TerrafierApp { + pub fn save_for_undo(&mut self, op: Box) { + 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(); + } + } +} diff --git a/gui/src/app/update.rs b/gui/src/app/update.rs new file mode 100644 index 0000000..67f248b --- /dev/null +++ b/gui/src/app/update.rs @@ -0,0 +1,91 @@ +use crate::app::{GpuStatus, TerrafierApp}; + +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() + && 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::view::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"); + }); + } + if matches!(self.gpu_status, GpuStatus::FallbackCpu(_)) { + ui.separator(); + ui.label(" CPU fallback"); + } + }); + }); + + if let Some(ref mut renderer) = self.renderer + && let Some(ref world) = self.world + && let Some(tile) = world.dimensions[0].tiles.values().next() + { + renderer.render( + &tile.heightmap.as_slice().try_into().expect("heightmap size"), + &tile.terrain.as_slice().try_into().expect("terrain size"), + self.show_heightmap, + ); + + if let Some(render_state) = frame.wgpu_render_state() { + let mut renderer_lock = render_state.renderer.write(); + if renderer.texture_id.is_none() { + renderer.register_texture(&mut renderer_lock); + } else { + renderer.update_texture(&mut renderer_lock); + } + } + } + + crate::dialogs::handle_dialogs(self, ctx); + } +} diff --git a/gui/src/lib.rs b/gui/src/lib.rs new file mode 100644 index 0000000..7a8bbee --- /dev/null +++ b/gui/src/lib.rs @@ -0,0 +1,6 @@ +mod app; +mod dialogs; +mod renderer; +mod view; + +pub use app::TerrafierApp; diff --git a/gui/src/main.rs b/gui/src/main.rs index d5de03c..71a94b9 100755 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -1,15 +1,15 @@ -fn main() -> Result<(), Box> { +fn main() -> Result<(), eframe::Error> { env_logger::init(); - let native_options = eframe::NativeOptions::default(); + + let native_options = eframe::NativeOptions { + renderer: eframe::Renderer::Wgpu, + viewport: egui::ViewportBuilder::default().with_inner_size([1280.0, 800.0]), + ..Default::default() + }; + eframe::run_native( "Terrafier", native_options, - Box::new(|_cc| Ok(Box::new(app::TerrafierApp::new()))), - )?; - Ok(()) + Box::new(|cc| Ok(Box::new(terrafier_gui::TerrafierApp::new(cc)))), + ) } - -mod app; -mod dialogs; -mod tools; -mod view; diff --git a/gui/src/renderer/mod.rs b/gui/src/renderer/mod.rs new file mode 100644 index 0000000..e57e317 --- /dev/null +++ b/gui/src/renderer/mod.rs @@ -0,0 +1,3 @@ +mod pipeline; +mod render; +pub use pipeline::*; diff --git a/gui/src/renderer/pipeline.rs b/gui/src/renderer/pipeline.rs new file mode 100644 index 0000000..7deb69b --- /dev/null +++ b/gui/src/renderer/pipeline.rs @@ -0,0 +1,140 @@ +use std::sync::Arc; + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct Uniforms { + pub(crate) min_height: i32, + pub(crate) max_height: i32, + pub(crate) show_heightmap: u32, + pub(crate) tile_size: u32, +} + +pub struct GpuRenderer { + pub(crate) device: Arc, + pub(crate) queue: Arc, + pub(crate) compute_pipeline: wgpu::ComputePipeline, + pub(crate) heightmap_buffer: wgpu::Buffer, + pub(crate) terrain_buffer: wgpu::Buffer, + pub(crate) uniform_buffer: wgpu::Buffer, + pub(crate) output_buffer: wgpu::Buffer, + pub(crate) output_texture: wgpu::Texture, + pub(crate) output_texture_view: wgpu::TextureView, + pub(crate) bind_group: wgpu::BindGroup, + pub texture_id: Option, + pub width: u32, + pub height: u32, + pub(crate) last_heightmap: Vec, + pub(crate) last_terrain: Vec, + pub(crate) last_show_heightmap: bool, + pub(crate) needs_upload: bool, +} + +impl GpuRenderer { + pub fn new( + device: Arc, + queue: Arc, + adapter: &wgpu::Adapter, + width: u32, + height: u32, + shader_source: &str, + ) -> Self { + let limits = adapter.limits(); + let needed_storage: u64 = 65536; + if limits.max_buffer_size < needed_storage { + log::warn!( + "Adapter buffer size limit ({}) < needed ({})", + limits.max_buffer_size, + needed_storage, + ); + } + let bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("terrain_bind_group_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }, count: None }, + wgpu::BindGroupLayoutEntry { binding: 1, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: true }, has_dynamic_offset: false, min_binding_size: None }, count: None }, + wgpu::BindGroupLayoutEntry { binding: 2, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: true }, has_dynamic_offset: false, min_binding_size: None }, count: None }, + wgpu::BindGroupLayoutEntry { binding: 3, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: false }, has_dynamic_offset: false, min_binding_size: None }, count: None }, + ], + }); + let shader_module = + device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("terrain_shader"), + source: wgpu::ShaderSource::Wgsl(shader_source.into()), + }); + let pipeline_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("terrain_pipeline_layout"), + bind_group_layouts: &[&bind_group_layout], + push_constant_ranges: &[], + }); + let compute_pipeline = + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("terrain_compute_pipeline"), + layout: Some(&pipeline_layout), + module: &shader_module, + entry_point: Some("main"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + cache: None, + }); + let heightmap_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("heightmap_buffer"), size: 8192 * 4, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, + }); + let terrain_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("terrain_buffer"), size: 4096 * 4, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, + }); + let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("uniform_buffer"), size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, + }); + let output_size = (width * height * 4) as u64; + let output_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("output_buffer"), size: output_size, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false, + }); + let output_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("output_texture"), + size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + view_formats: &[wgpu::TextureFormat::Rgba8UnormSrgb], + usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING, + }); + let output_texture_view = output_texture.create_view(&Default::default()); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("terrain_bind_group"), + layout: &bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: &uniform_buffer, offset: 0, size: None }) }, + wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: &heightmap_buffer, offset: 0, size: None }) }, + wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: &terrain_buffer, offset: 0, size: None }) }, + wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: &output_buffer, offset: 0, size: None }) }, + ], + }); + Self { + device, queue, compute_pipeline, heightmap_buffer, terrain_buffer, + uniform_buffer, output_buffer, output_texture, output_texture_view, bind_group, + texture_id: None, width, height, + last_heightmap: vec![0i16; 16384], + last_terrain: vec![0u8; 16384], + last_show_heightmap: false, + needs_upload: true, + } + } + + pub(crate) fn pack_heightmap(heightmap: &[i16; 16384]) -> Vec { + heightmap.chunks(2).map(|chunk| { + let low = chunk[0] as u32 & 0xFFFF; + let high = chunk[1] as u32 & 0xFFFF; + low | (high << 16) + }).collect() + } + + pub(crate) fn pack_terrain(terrain: &[u8; 16384]) -> Vec { + terrain.chunks(4).map(|chunk| { + chunk[0] as u32 | (chunk[1] as u32) << 8 | (chunk[2] as u32) << 16 | (chunk[3] as u32) << 24 + }).collect() + } +} diff --git a/gui/src/renderer/render.rs b/gui/src/renderer/render.rs new file mode 100644 index 0000000..05fbc9c --- /dev/null +++ b/gui/src/renderer/render.rs @@ -0,0 +1,123 @@ +use crate::renderer::GpuRenderer; +use crate::renderer::Uniforms; + +fn as_u8_slice(data: &[T]) -> &[u8] { + unsafe { + std::slice::from_raw_parts( + data.as_ptr() as *const u8, + std::mem::size_of_val(data), + ) + } +} + +fn as_u8_value(data: &T) -> &[u8] { + unsafe { + std::slice::from_raw_parts( + data as *const T as *const u8, + std::mem::size_of::(), + ) + } +} + +impl GpuRenderer { + pub fn render( + &mut self, + heightmap: &[i16; 16384], + terrain: &[u8; 16384], + show_heightmap: bool, + ) { + if !self.needs_upload + && heightmap == &self.last_heightmap[..] + && terrain == &self.last_terrain[..] + && show_heightmap == self.last_show_heightmap + { + return; + } + + self.last_heightmap.copy_from_slice(heightmap); + self.last_terrain.copy_from_slice(terrain); + self.last_show_heightmap = show_heightmap; + self.needs_upload = false; + + let min_height = *heightmap.iter().min().unwrap(); + let max_height = *heightmap.iter().max().unwrap(); + + let packed_heightmap = Self::pack_heightmap(heightmap); + self.queue + .write_buffer(&self.heightmap_buffer, 0, as_u8_slice(&packed_heightmap)); + + let packed_terrain = Self::pack_terrain(terrain); + self.queue + .write_buffer(&self.terrain_buffer, 0, as_u8_slice(&packed_terrain)); + + let uniforms = Uniforms { + min_height: min_height as i32, + max_height: max_height as i32, + show_heightmap: if show_heightmap { 1 } else { 0 }, + tile_size: 128, + }; + self.queue + .write_buffer(&self.uniform_buffer, 0, as_u8_value(&uniforms)); + + let mut encoder = self.device.create_command_encoder(&Default::default()); + + { + let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("terrain_compute_pass"), + timestamp_writes: None, + }); + cpass.set_pipeline(&self.compute_pipeline); + cpass.set_bind_group(0, &self.bind_group, &[]); + cpass.dispatch_workgroups(8, 8, 1); + } + + let bytes_per_row = self.width * 4; + encoder.copy_buffer_to_texture( + wgpu::TexelCopyBufferInfo { + buffer: &self.output_buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(bytes_per_row), + rows_per_image: Some(self.height), + }, + }, + wgpu::TexelCopyTextureInfo { + texture: &self.output_texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::Extent3d { + width: self.width, + height: self.height, + depth_or_array_layers: 1, + }, + ); + + self.queue.submit(Some(encoder.finish())); + } + + pub fn register_texture(&mut self, renderer: &mut egui_wgpu::Renderer) { + let id = renderer.register_native_texture( + &self.device, + &self.output_texture_view, + wgpu::FilterMode::Linear, + ); + self.texture_id = Some(id); + } + + pub fn update_texture(&mut self, renderer: &mut egui_wgpu::Renderer) { + if let Some(id) = self.texture_id { + renderer.update_egui_texture_from_wgpu_texture( + &self.device, + &self.output_texture_view, + wgpu::FilterMode::Linear, + id, + ); + } + } + + pub fn mark_dirty(&mut self) { + self.needs_upload = true; + } +} diff --git a/gui/src/renderer/shaders.wgsl b/gui/src/renderer/shaders.wgsl new file mode 100644 index 0000000..0322b8e --- /dev/null +++ b/gui/src/renderer/shaders.wgsl @@ -0,0 +1,114 @@ +struct Uniforms { + min_height: i32, + max_height: i32, + show_heightmap: u32, + tile_size: u32, +} + +@group(0) @binding(0) var uniforms: Uniforms; +@group(0) @binding(1) var heightmap_packed: array; +@group(0) @binding(2) var terrain_packed: array; +@group(0) @binding(3) var output: array; + +fn unpack_heightmap(data: array, index: u32) -> i32 { + let packed = data[index / 2u]; + var val: i32; + if index % 2u == 0u { + val = i32(packed & 0xFFFFu); + } else { + val = i32(packed >> 16u); + } + if val & 0x8000 != 0 { + val = val | 0xFFFF0000; + } + return val; +} + +fn unpack_terrain(data: array, index: u32) -> u32 { + let packed = data[index / 4u]; + let shift = (index % 4u) * 8u; + return (packed >> shift) & 0xFFu; +} + +fn terrain_color(id: u32, height_factor: f32) -> vec3 { + var color: vec3; + switch id { + case 0u: { + color = vec3(194.0, 178.0, 128.0); + } + case 1u: { + color = vec3(124.0, 189.0, 107.0); + } + case 2u: { + color = vec3(86.0, 140.0, 74.0); + } + case 3u: { + color = vec3(128.0, 128.0, 128.0); + } + case 4u: { + color = vec3(227.0, 212.0, 160.0); + } + case 5u: { + color = vec3(72.0, 107.0, 75.0); + } + case 6u: { + color = vec3(64.0, 128.0, 255.0); + } + default: { + color = vec3(128.0, 128.0, 128.0); + } + } + return color * height_factor; +} + +fn height_color(norm: f32) -> vec3 { + var color: vec3; + if norm < 0.25 { + let t = norm / 0.25; + color = mix(vec3(0.0, 0.0, 255.0), vec3(0.0, 255.0, 255.0), t); + } else if norm < 0.5 { + let t = (norm - 0.25) / 0.25; + color = mix(vec3(0.0, 255.0, 255.0), vec3(0.0, 255.0, 0.0), t); + } else if norm < 0.75 { + let t = (norm - 0.5) / 0.25; + color = mix(vec3(0.0, 255.0, 0.0), vec3(255.0, 255.0, 0.0), t); + } else { + let t = (norm - 0.75) / 0.25; + color = mix(vec3(255.0, 255.0, 0.0), vec3(255.0, 0.0, 0.0), t); + } + return color; +} + +@compute @workgroup_size(16, 16, 1) +fn main(@builtin(global_invocation_id) id: vec3) { + if id.x >= uniforms.tile_size || id.y >= uniforms.tile_size { + return; + } + + let index = id.y * uniforms.tile_size + id.x; + + let height = unpack_heightmap(heightmap_packed, index); + let terrain_id = unpack_terrain(terrain_packed, index); + + let height_factor = 0.7 + 0.3 * ((f32(height) + 64.0) / 384.0); + + var color: vec3; + if uniforms.show_heightmap != 0u { + let height_range = f32(uniforms.max_height - uniforms.min_height); + var norm: f32; + if height_range <= 0.0 { + norm = 0.5; + } else { + norm = (f32(height) - f32(uniforms.min_height)) / height_range; + } + norm = clamp(norm, 0.0, 1.0); + color = height_color(norm); + } else { + color = terrain_color(terrain_id, height_factor); + } + + output[index] = (u32(color.r) & 0xFFu) | + ((u32(color.g) & 0xFFu) << 8u) | + ((u32(color.b) & 0xFFu) << 16u) | + 0xFF000000u; +} diff --git a/gui/src/view.rs b/gui/src/view/cpu.rs old mode 100755 new mode 100644 similarity index 64% rename from gui/src/view.rs rename to gui/src/view/cpu.rs index 899d816..b161765 --- a/gui/src/view.rs +++ b/gui/src/view/cpu.rs @@ -1,6 +1,58 @@ use egui::{Color32, ColorImage, TextureOptions, Vec2}; +use terrafier_core::model::tile::{TILE_SIZE, LayerBuffer}; +use terrafier_core::model::layers::{LAYER_CAVES, LAYER_RIVER, LAYER_FROST, LAYER_TREES, LAYER_BIOME, LAYER_RESOURCES}; +use crate::app::{LAYER_CAVES_IDX, LAYER_RIVER_IDX, LAYER_FROST_IDX, LAYER_TREES_IDX, LAYER_BIOME_IDX, LAYER_RESOURCES_IDX}; -use terrafier_core::model::tile::TILE_SIZE; +const LAYER_IDS: [u32; 6] = [LAYER_CAVES, LAYER_RIVER, LAYER_FROST, LAYER_TREES, LAYER_BIOME, LAYER_RESOURCES]; + +fn layer_value(tile: &terrafier_core::model::tile::Tile, idx: usize, layer_idx: usize) -> Option { + let layer_id = LAYER_IDS[layer_idx]; + let buf = tile.layer_data.get(&layer_id)?; + let val = match buf { + LayerBuffer::Bit(data) => { + let word = data.get(idx / 64)?; + Some(if (word >> (idx % 64)) & 1 != 0 { 1.0 } else { 0.0 }) + } + LayerBuffer::Nibble(data) => { + let byte = *data.get(idx / 2)?; + let nib = if idx % 2 == 0 { byte & 0x0F } else { (byte >> 4) & 0x0F }; + Some(nib as f32 / 15.0) + } + LayerBuffer::Byte(data) => { + let v = *data.get(idx)? as f32; + Some(v / 255.0) + } + LayerBuffer::Int(_) => None, + }; + val +} + +fn layer_overlay(layer_idx: usize, value: f32) -> Option<(u8, u8, u8)> { + if value <= 0.0 { + return None; + } + match layer_idx { + LAYER_CAVES_IDX => Some(((value * 150.0) as u8, 0, 0)), + LAYER_RIVER_IDX => Some((0, 0, (128.0 + value * 127.0) as u8)), + LAYER_FROST_IDX => Some((255, 255, 255)), + LAYER_TREES_IDX => Some((0, (64.0 + value * 191.0) as u8, 0)), + LAYER_BIOME_IDX => { + let h = (value * 255.0) as u8; + Some((h.wrapping_mul(7), h.wrapping_mul(3), h)) + } + LAYER_RESOURCES_IDX => Some(((value * 255.0) as u8, (value * 200.0) as u8, 0)), + _ => None, + } +} + +fn blend_overlay(base: (u8, u8, u8), overlay: (u8, u8, u8), alpha: f32) -> (u8, u8, u8) { + let a = alpha.clamp(0.0, 1.0); + ( + (base.0 as f32 * (1.0 - a) + overlay.0 as f32 * a) as u8, + (base.1 as f32 * (1.0 - a) + overlay.1 as f32 * a) as u8, + (base.2 as f32 * (1.0 - a) + overlay.2 as f32 * a) as u8, + ) +} const TERRAIN_COLORS: [Color32; 7] = [ Color32::from_rgb(194, 178, 128), // Desert @@ -12,13 +64,8 @@ const TERRAIN_COLORS: [Color32; 7] = [ 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; - }; +pub fn render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) { + let Some(ref world) = app.world else { return; }; let dim = &world.dimensions[0]; let mut min_tx = i32::MAX; @@ -54,23 +101,18 @@ pub fn show_viewport(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) { 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 (mut r, mut g, mut b) = if app.show_heightmap { 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) } @@ -81,6 +123,17 @@ pub fn show_viewport(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) { (r, g, b) }; + for li in 0..6 { + if app.layer_visible[li] { + if let Some(val) = layer_value(tile, idx, li) { + if let Some(over) = layer_overlay(li, val) { + let alpha = if val > 0.0 { (val * 0.6 + 0.2).min(0.8) } else { 0.0 }; + (r, g, b) = blend_overlay((r, g, b), over, alpha); + } + } + } + } + let di = (pz + ly) * grid_w + (px + lx); img_data[di * 4] = r; img_data[di * 4 + 1] = g; @@ -91,22 +144,29 @@ pub fn show_viewport(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) { } 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 texture_id = if let Some(ref mut handle) = app.cpu_texture_handle { + handle.set(color_image, TextureOptions::LINEAR); + handle.id() + } else { + let handle = ui + .ctx() + .load_texture("world_map", color_image, TextureOptions::LINEAR); + let id = handle.id(); + app.cpu_texture_handle = Some(handle); + id + }; 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(), + texture_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 && sel_tx >= min_tx && sel_tx <= max_tx @@ -125,7 +185,6 @@ pub fn show_viewport(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) { ); } - // 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) { @@ -143,7 +202,6 @@ pub fn show_viewport(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) { ); } - // Handle click to select tile if response.clicked() && let Some(pos) = response.interact_pointer_pos() { diff --git a/gui/src/view/gpu.rs b/gui/src/view/gpu.rs new file mode 100644 index 0000000..0b275cd --- /dev/null +++ b/gui/src/view/gpu.rs @@ -0,0 +1,109 @@ +use egui::{Color32, Vec2}; +use terrafier_core::model::tile::TILE_SIZE; + +pub fn try_render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) -> bool { + let Some(ref world) = app.world else { + return false; + }; + let dim = &world.dimensions[0]; + + if let Some(ref renderer) = app.renderer + && let Some(texture_id) = renderer.texture_id + { + let desired_size = Vec2::new(512.0, 512.0); + let (response, painter) = ui.allocate_painter(desired_size, egui::Sense::click()); + let rect = response.rect; + painter.image( + texture_id, + rect, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + Color32::WHITE, + ); + + let display_size = rect.width() / 4.0; + + if let Some((sel_tx, sel_tz)) = app.selected_tile { + 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 bx = rect.min.x; + let bz = rect.min.y; + let sel_bx = bx + (sel_tx - min_tx) as f32 * display_size; + let sel_bz = bz + (sel_tz - min_tz) as f32 * display_size; + let border_rect = egui::Rect::from_min_size( + egui::pos2(sel_bx, sel_bz), + Vec2::splat(display_size), + ); + painter.rect_stroke( + border_rect, + 0.0, + egui::Stroke::new(3.0, Color32::WHITE), + egui::StrokeKind::Middle, + ); + } + + if let (Some((sel_tx, sel_tz)), Some(bx), Some(bz)) = + (app.selected_tile, app.brush_local_x, app.brush_local_z) + { + let mut min_tx = i32::MAX; + let mut min_tz = i32::MAX; + for &(tx, tz) in dim.tiles.keys() { + min_tx = min_tx.min(tx); + min_tz = min_tz.min(tz); + } + let tile_x_in_pixels = (sel_tx - min_tx) as f32 * display_size; + let tile_z_in_pixels = (sel_tz - min_tz) as f32 * display_size; + let brush_x = rect.min.x + tile_x_in_pixels + + (bx as f32 * display_size / TILE_SIZE as f32); + let brush_z = rect.min.y + tile_z_in_pixels + + (bz as f32 * display_size / TILE_SIZE as f32); + let brush_radius_px = app.brush_radius as f32 * display_size / 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), + ); + } + + if response.clicked() + && 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 mut min_tx = i32::MAX; + let mut min_tz = i32::MAX; + for &(tx, tz) in dim.tiles.keys() { + min_tx = min_tx.min(tx); + min_tz = min_tz.min(tz); + } + let tx = (lx / display_size).floor() as i32 + min_tx; + let tz = (lz / display_size).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 as u32) * TILE_SIZE as u32 / display_size as u32).min(127); + let local_z = + ((lz as u32 % display_size as u32) * TILE_SIZE as u32 / display_size as u32).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 + ); + } + } + } + + return true; + } + false +} diff --git a/gui/src/view/mod.rs b/gui/src/view/mod.rs new file mode 100644 index 0000000..faa1bc0 --- /dev/null +++ b/gui/src/view/mod.rs @@ -0,0 +1,19 @@ +mod cpu; +mod gpu; +mod tools; + +pub use tools::show_tools_panel; + +pub fn show_viewport(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) { + if app.world.is_none() { + ui.centered_and_justified(|ui| { + ui.heading("No world loaded. Click 'New World' to begin."); + }); + return; + } + + if gpu::try_render(ui, app) { + return; + } + cpu::render(ui, app); +} diff --git a/gui/src/tools.rs b/gui/src/view/tools.rs old mode 100755 new mode 100644 similarity index 76% rename from gui/src/tools.rs rename to gui/src/view/tools.rs index 5ccc732..da66958 --- a/gui/src/tools.rs +++ b/gui/src/view/tools.rs @@ -1,14 +1,14 @@ use std::sync::Arc; use terrafier_core::Terrain; -use terrafier_core::model::brush::SymmetricBrush; +use terrafier_core::model::types::SymmetricBrush; use terrafier_core::model::tile::TILE_SIZE; use terrafier_core::ops::operations::{ - FlattenOperation, HeightOperation, MultiTileOperation, Operation, PaintOperation, - SmoothOperation, + ErodeOperation, FillOperation, FlattenOperation, HeightOperation, MultiTileOperation, + Operation, PaintOperation, SmoothOperation, }; -use crate::app::{TerrafierApp, ToolMode}; +use crate::app::{TerrafierApp, ToolMode, LAYER_NAMES}; pub fn show_tools_panel(ui: &mut egui::Ui, app: &mut TerrafierApp) { ui.heading("Tools"); @@ -20,6 +20,8 @@ pub fn show_tools_panel(ui: &mut egui::Ui, app: &mut TerrafierApp) { 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::Erode, "Erode"); + ui.radio_value(&mut app.tool_mode, ToolMode::Fill, "Fill"); ui.radio_value(&mut app.tool_mode, ToolMode::Inspect, "Inspect"); ui.separator(); @@ -34,7 +36,12 @@ pub fn show_tools_panel(ui: &mut egui::Ui, app: &mut TerrafierApp) { ui.add(egui::Slider::new(&mut app.target_height, -64..=320).text("Height")); } - if app.tool_mode == ToolMode::Paint { + if app.tool_mode == ToolMode::Erode { + ui.add(egui::Slider::new(&mut app.erode_iterations, 1..=20).text("Iterations")); + ui.add(egui::Slider::new(&mut app.erode_talus_angle, 0.0..=1.0).text("Talus angle")); + } + + if app.tool_mode == ToolMode::Paint || app.tool_mode == ToolMode::Fill { ui.label("Terrain:"); egui::ComboBox::from_label("Type") .selected_text(app.selected_terrain.name()) @@ -52,6 +59,12 @@ pub fn show_tools_panel(ui: &mut egui::Ui, app: &mut TerrafierApp) { ui.separator(); ui.checkbox(&mut app.show_heightmap, "Heightmap view"); + ui.separator(); + ui.heading("Layers"); + for (i, name) in LAYER_NAMES.iter().enumerate() { + ui.checkbox(&mut app.layer_visible[i], *name); + } + ui.separator(); if ui.button("Apply to selected tile").clicked() { apply_tool(app); @@ -75,7 +88,6 @@ fn apply_tool(app: &mut TerrafierApp) { 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(); @@ -89,7 +101,30 @@ fn apply_tool(app: &mut TerrafierApp) { return; } - // Create the operation — always use MultiTileOperation for consistency + if app.tool_mode == ToolMode::Fill { + if dim.tiles.contains_key(&(tx, tz)) { + let op = FillOperation { + tile_x: tx, + tile_z: tz, + center_x: center, + center_z, + terrain: app.selected_terrain, + before_snapshot: Default::default(), + }; + let result = op.apply(dim); + app.save_for_undo(Box::new(op)); + match result { + Ok(()) => { + app.status_message = format!("Fill applied to ({},{})", tx, tz); + } + Err(e) => { + app.status_message = format!("Fill error: {:?}", e); + } + } + } + return; + } + 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; @@ -161,6 +196,18 @@ fn apply_tool(app: &mut TerrafierApp) { brush, before_snapshot: Default::default(), }), + ToolMode::Erode => Box::new(ErodeOperation { + tile_x: otx, + tile_z: otz, + center_x: local_cx, + center_z: local_cz, + radius: app.brush_radius, + iterations: app.erode_iterations, + talus_angle: app.erode_talus_angle, + brush, + before_snapshot: Default::default(), + }), + ToolMode::Fill => unreachable!(), ToolMode::Inspect => unreachable!(), }; ops.push(op); @@ -175,10 +222,8 @@ fn apply_tool(app: &mut TerrafierApp) { 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 {