Refactor structure, implement layers, add Erode/Fill/layer panel

- Restructure folder hierarchy: max 4 .rs per dir using subfolders
  (ops/operations -> brush/ + shaping/, model/ -> world/, io/* -> subtypes, etc.)
- Fix golden_test.rs comment, extract BrushApply helper, add World::with_heightmap
- Implement 6 layer data generators (Caves, River, Frost, Trees, Biome, Resources)
  with noise-based (OpenSimplex) and terrain-derived algorithms
- Fix clippy warnings, dead code cleanup, import path fixes
- GUI: add Erode&Fill tool modes with radio buttons/sliders/apply_tool
- GUI: add Layers panel with 6 checkboxes + CPU viewport overlay rendering
This commit is contained in:
loki5512344 2026-06-24 12:27:55 +02:00
parent 3d3798136d
commit bdd4564240
104 changed files with 5184 additions and 3912 deletions

View file

@ -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

View file

@ -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<T> = std::result::Result<T, BinaryError>;
/// 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<World> {
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(())
}

View file

@ -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
}
}
}

View file

@ -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
}
}
}

View file

@ -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
}
}
}

View file

@ -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};

View file

@ -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
}
}
}

View file

@ -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<T> = std::result::Result<T, MinecraftIOError>;
/// Infer Minecraft version from DataVersion number.
pub fn version_from_data_version(dv: i32) -> Option<Platform> {
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<World> {
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(&region_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::<i32>(), parts[2].parse::<i32>())
{
let region_bytes = fs::read(&path)?;
let region = Region::from_bytes(rx, rz, &region_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 &regions {
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), &region_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<i64> {
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<Vec<u8>> {
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<terrafier_nbt::Tag> = 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<terrafier_nbt::Tag> = 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<Vec<u8>> {
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<terrafier_nbt::Tag> = 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<u16> = 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<terrafier_nbt::Tag> = 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 &regions {
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), &region_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<terrafier_nbt::Tag> {
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<Vec<PathBuf>> {
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(&region_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|e| e == "mca") {
files.push(path);
}
}
files.sort();
Ok(files)
}

View file

@ -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<i64> {
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"
}
}

View file

@ -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<String, terrafier_nbt::Tag>,
biomes: HashMap<String, terrafier_nbt::Tag>,
) -> 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<String, terrafier_nbt::Tag> {
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<Vec<u8>> {
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<terrafier_nbt::Tag> = 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<terrafier_nbt::Tag> = 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<Vec<u8>> {
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<terrafier_nbt::Tag> = 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<u16> = 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<terrafier_nbt::Tag> = 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)
}

View file

@ -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;

View file

@ -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 &regions {
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), &region_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 &regions {
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), &region_bytes)?;
}
Ok(())
}
fn build_level_dat(world: &World) -> Result<terrafier_nbt::Tag> {
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))
}

View file

@ -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<T> = std::result::Result<T, MinecraftIOError>;

View file

@ -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<World> {
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(&region_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::<i32>(), parts[2].parse::<i32>())
{
let region_bytes = fs::read(&path)?;
let region = Region::from_bytes(rx, rz, &region_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<Vec<PathBuf>> {
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(&region_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|e| e == "mca") {
files.push(path);
}
}
files.sort();
Ok(files)
}

View file

@ -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<Platform> {
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,
}
}

View file

@ -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<T> = std::result::Result<T, BinaryError>;
/// 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<World> {
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(())
}

File diff suppressed because it is too large Load diff

View file

@ -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<WpTileData>,
}
pub fn extract_world_data(val: &JvmValue) -> Result<WpWorldData> {
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<WpTileData>) {
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<WpTileData> {
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,
}
}

View file

@ -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<u8>,
pub pos: usize,
pub handles: Vec<JvmValue>,
pub class_descs: Vec<ClassDesc>,
}
impl JvmStream {
pub fn new(data: Vec<u8>) -> Self {
JvmStream {
data,
pos: 0,
handles: Vec::new(),
class_descs: Vec::new(),
}
}
pub fn read_byte(&mut self) -> std::io::Result<u8> {
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<Vec<u8>> {
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<u8> {
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<u16> {
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<i32> {
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<i64> {
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<f32> {
Ok(f32::from_bits(self.read_i32()? as u32))
}
pub fn read_f64(&mut self) -> std::io::Result<f64> {
Ok(f64::from_bits(self.read_i64()? as u64))
}
pub fn read_utf(&mut self) -> std::io::Result<String> {
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<String> {
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<JvmValue> {
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)
}
}

View file

@ -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<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn import(&self) -> Result<World> {
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<World> {
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")));
}
}

View file

@ -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<JvmValue> {
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<JvmValue> {
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<JvmValue> {
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)
}
}

View file

@ -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<JvmValue> {
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<String> {
None
}
pub fn read_object_field(&mut self, field: &FieldInfo) -> std::io::Result<JvmValue> {
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<JvmValue> {
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<i8> = 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<JvmValue> {
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<JvmValue> {
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<JvmValue> {
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<ClassDesc> {
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(())
}
}
}
}
}

View file

@ -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<T> = std::result::Result<T, WpImportError>;
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<FieldInfo>,
}
#[derive(Debug, Clone)]
pub enum JvmValue {
Null,
String(String),
ClassDesc(ClassDesc),
Object(HashMap<String, JvmValue>),
ByteArray(Vec<i8>),
ShortArray(Vec<i16>),
IntArray(Vec<i32>),
LongArray(()),
Int(i32),
Long(i64),
Skipped,
}
impl JvmValue {
pub fn as_string(&self) -> Option<String> {
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<i32> {
match self {
JvmValue::Int(v) => Some(*v),
_ => None,
}
}
pub fn as_u64(&self) -> Option<u64> {
match self {
JvmValue::Long(v) => Some(*v as u64),
JvmValue::Int(v) => Some(*v as u64),
_ => None,
}
}
pub fn as_object(&self) -> Option<&HashMap<String, JvmValue>> {
match self {
JvmValue::Object(m) => Some(m),
_ => None,
}
}
}

View file

@ -0,0 +1,5 @@
mod java_classdesc;
mod java_fields;
mod java_types;
pub use java_types::*;

View file

@ -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;

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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;

View file

@ -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 {

View file

@ -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::*;

View file

@ -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,
}
}
}

View file

@ -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",
}
}
}

74
core/src/model/types.rs Normal file
View file

@ -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",
}
}
}

View file

@ -0,0 +1,5 @@
pub mod dimension;
pub mod world;
pub use dimension::*;
pub use world::*;

View file

@ -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);
}

View file

@ -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<dyn Operation>,
@ -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))

View file

@ -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.

183
core/src/ops/layers/mod.rs Normal file
View file

@ -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");
}
}
}

View file

@ -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);
}
}
}

View file

@ -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);
}
}
}

View file

@ -0,0 +1,7 @@
mod caves;
mod frost;
mod river;
pub use caves::CavesGenerator;
pub use frost::FrostGenerator;
pub use river::RiverGenerator;

View file

@ -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);
}
}
}

View file

@ -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<u8> {
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
}
}

View file

@ -0,0 +1,7 @@
pub mod biome;
mod resources;
mod trees;
pub use biome::BiomeGenerator;
pub use resources::ResourcesGenerator;
pub use trees::TreesGenerator;

View file

@ -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);
}
}

View file

@ -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);
}
}
}

View file

@ -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;

View file

@ -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<dyn Operation>;
}
#[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<dyn crate::model::brush::Brush>,
pub before_snapshot: OnceLock<Vec<(usize, i16)>>,
}
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<dyn Operation> {
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<dyn crate::model::brush::Brush>,
pub before_snapshot: OnceLock<Vec<(usize, i16)>>,
}
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<dyn Operation> {
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<dyn crate::model::brush::Brush>,
pub before_snapshot: OnceLock<Vec<(usize, u8)>>,
}
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<dyn Operation> {
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<dyn Operation> {
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<dyn Operation> {
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<dyn crate::model::brush::Brush>,
pub before_snapshot: OnceLock<Vec<(usize, i16)>>,
}
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<dyn Operation> {
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<dyn crate::model::brush::Brush>,
pub before_snapshot: OnceLock<Vec<(usize, i16)>>,
}
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<dyn Operation> {
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<Vec<(usize, u8)>>,
}
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<dyn Operation> {
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<dyn crate::model::brush::Brush>,
pub before_snapshot: OnceLock<Vec<(usize, u8)>>,
}
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<dyn Operation> {
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<Vec<(usize, u8)>>,
}
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<dyn Operation> {
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<Box<dyn Operation>>,
}
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<dyn Operation> {
let mut inverses: Vec<Box<dyn Operation>> = 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<dyn Operation> {
Box::new(NoOpOperation)
}
}

View file

@ -0,0 +1,2 @@
mod paint;
pub use paint::*;

View file

@ -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<Vec<(usize, u8)>>,
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<dyn Brush>,
pub before_snapshot: OnceLock<Vec<(usize, u8)>>,
}
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<dyn Operation> {
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<dyn Brush>,
pub before_snapshot: OnceLock<Vec<(usize, u8)>>,
}
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<dyn Operation> {
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<Vec<(usize, u8)>>,
}
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<dyn Operation> {
let snapshot = self.before_snapshot.get().cloned().unwrap_or_default();
Box::new(RestoreTerrainOperation {
tile_x: self.tile_x,
tile_z: self.tile_z,
snapshot,
})
}
}

View file

@ -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<Vec<(usize, u8)>>,
}
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<dyn Operation> {
let snapshot = self.before_snapshot.get().cloned().unwrap_or_default();
Box::new(RestoreTerrainOperation {
tile_x: self.tile_x,
tile_z: self.tile_z,
snapshot,
})
}
}

View file

@ -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<dyn Operation>;
}
#[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<dyn Operation> {
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<dyn Operation> {
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<dyn Operation> {
Box::new(NoOpOperation)
}
}

View file

@ -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<dyn Brush>,
pub before_snapshot: OnceLock<Vec<(usize, i16)>>,
}
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<dyn Operation> {
let snapshot = self.before_snapshot.get().cloned().unwrap_or_default();
Box::new(RestoreHeightsOperation {
tile_x: self.tile_x,
tile_z: self.tile_z,
snapshot,
})
}
}

View file

@ -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<dyn Brush>,
pub before_snapshot: OnceLock<Vec<(usize, i16)>>,
}
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<dyn Operation> {
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<dyn Brush>,
pub before_snapshot: OnceLock<Vec<(usize, i16)>>,
}
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<dyn Operation> {
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<Box<dyn Operation>>,
}
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<dyn Operation> {
let mut inverses: Vec<Box<dyn Operation>> = Vec::with_capacity(self.operations.len());
for op in &self.operations {
inverses.push(op.inverse());
}
inverses.reverse();
Box::new(MultiTileOperation {
operations: inverses,
})
}
}

View file

@ -0,0 +1,6 @@
mod height;
mod smooth;
mod erode;
pub use height::*;
pub use smooth::*;
pub use erode::*;

View file

@ -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<dyn Brush>,
pub before_snapshot: OnceLock<Vec<(usize, i16)>>,
}
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<dyn Operation> {
let snapshot = self.before_snapshot.get().cloned().unwrap_or_default();
Box::new(RestoreHeightsOperation {
tile_x: self.tile_x,
tile_z: self.tile_z,
snapshot,
})
}
}

View file

@ -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<dyn std::error::Error>>;
fn format_name(&self) -> &'static str;
}

View file

@ -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<Box<dyn Layer>>;
}

View file

@ -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;

View file

@ -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<dyn Fn() -> Box<dyn Operation>>>;
}

View file

@ -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);
}

View file

@ -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<dyn std::error::Error>>;
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<Box<dyn Layer>>;
}
pub trait OperationPlugin: Send + Sync {
fn name(&self) -> &'static str;
fn version(&self) -> &'static str;
fn operations(&self) -> Vec<Box<dyn Fn() -> Box<dyn Operation>>>;
}
pub trait TileSourcePlugin: Send + Sync {
fn name(&self) -> &'static str;
fn version(&self) -> &'static str;
fn generate_tile(&self, tile: &mut Tile, seed: u64);
}

View file

@ -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, &region_bytes).expect("region should parse");