Initial commit: Terrafier v0.1.0
- Core world model (World, Dimension, Tile, Terrain, Platform) - NBT reader/writer with full tag support - Anvil region (.mca) format reader with chunk parsing - Block palette compression and export - Biome database with Minecraft 1.21 biomes - CLI commands: new, import, export, info, render - GUI application with egui (viewport, tools, undo/redo) - Noise-based heightmap generation and editing operations License: GPL-3.0-or-later
This commit is contained in:
commit
91835402b3
94 changed files with 12372 additions and 0 deletions
25
core/Cargo.toml
Executable file
25
core/Cargo.toml
Executable file
|
|
@ -0,0 +1,25 @@
|
|||
[package]
|
||||
name = "terrafier-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Core library for Terrafier — world model, Minecraft I/O, export pipeline"
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
rayon.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
terrafier-nbt = { path = "../crates/nbt" }
|
||||
terrafier-fastanvil = { path = "../crates/fastanvil" }
|
||||
terrafier-noise = { path = "../crates/noise" }
|
||||
|
||||
serde-big-array.workspace = true
|
||||
|
||||
bincode.workspace = true
|
||||
|
||||
image.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
135
core/src/coords/mod.rs
Executable file
135
core/src/coords/mod.rs
Executable file
|
|
@ -0,0 +1,135 @@
|
|||
//! Coordinate system for Terrafier.
|
||||
//!
|
||||
//! All conversions are done through explicit functions, not inline arithmetic.
|
||||
|
||||
/// Block coordinates (absolute, within the Minecraft world).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct BlockCoords {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub z: i32,
|
||||
}
|
||||
|
||||
/// Chunk coordinates (16x16 block columns).
|
||||
/// chunk_x = block_x >> 4, chunk_z = block_z >> 4
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct ChunkCoords {
|
||||
pub x: i32,
|
||||
pub z: i32,
|
||||
}
|
||||
|
||||
/// Region coordinates (32x32 chunk areas = 512x512 blocks).
|
||||
/// region_x = chunk_x >> 5, region_z = chunk_z >> 5
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct RegionCoords {
|
||||
pub x: i32,
|
||||
pub z: i32,
|
||||
}
|
||||
|
||||
/// Terrafier Tile coordinates (128x128 blocks internally).
|
||||
/// tile_x = block_x >> 7, tile_z = block_z >> 7
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct TileCoords {
|
||||
pub x: i32,
|
||||
pub z: i32,
|
||||
}
|
||||
|
||||
/// Local coordinates within a tile (0..127).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct LocalTileCoords {
|
||||
pub x: u32,
|
||||
pub z: u32,
|
||||
}
|
||||
|
||||
/// Local coordinates within a chunk (0..15).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct LocalChunkCoords {
|
||||
pub x: u8,
|
||||
pub z: u8,
|
||||
}
|
||||
|
||||
// === Conversions ===
|
||||
|
||||
impl BlockCoords {
|
||||
pub fn new(x: i32, y: i32, z: i32) -> Self {
|
||||
Self { x, y, z }
|
||||
}
|
||||
|
||||
pub fn to_chunk(self) -> ChunkCoords {
|
||||
ChunkCoords {
|
||||
x: self.x >> 4,
|
||||
z: self.z >> 4,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_region(self) -> RegionCoords {
|
||||
RegionCoords {
|
||||
x: self.x >> 9,
|
||||
z: self.z >> 9,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_tile(self) -> TileCoords {
|
||||
TileCoords {
|
||||
x: self.x >> 7,
|
||||
z: self.z >> 7,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ChunkCoords {
|
||||
pub fn new(x: i32, z: i32) -> Self {
|
||||
Self { x, z }
|
||||
}
|
||||
|
||||
pub fn to_region(self) -> RegionCoords {
|
||||
RegionCoords {
|
||||
x: self.x >> 5,
|
||||
z: self.z >> 5,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_block_min(self) -> BlockCoords {
|
||||
BlockCoords {
|
||||
x: self.x << 4,
|
||||
y: i32::MIN,
|
||||
z: self.z << 4,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn local_in_region(self) -> LocalChunkCoords {
|
||||
LocalChunkCoords {
|
||||
x: (self.x & 31) as u8,
|
||||
z: (self.z & 31) as u8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RegionCoords {
|
||||
pub fn new(x: i32, z: i32) -> Self {
|
||||
Self { x, z }
|
||||
}
|
||||
|
||||
/// Region file name like "r.x.z.mca"
|
||||
pub fn file_name(&self) -> String {
|
||||
format!("r.{}.{}.mca", self.x, self.z)
|
||||
}
|
||||
}
|
||||
|
||||
impl TileCoords {
|
||||
pub fn new(x: i32, z: i32) -> Self {
|
||||
Self { x, z }
|
||||
}
|
||||
|
||||
pub fn local_in_tile(self, block: BlockCoords) -> LocalTileCoords {
|
||||
LocalTileCoords {
|
||||
x: (block.x & 127) as u32,
|
||||
z: (block.z & 127) as u32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Terrafier tile size constant.
|
||||
pub const TILE_SIZE: usize = 128;
|
||||
pub const TILE_SIZE_BITS: u32 = 7;
|
||||
pub const TILE_SIZE_MASK: u32 = 127;
|
||||
51
core/src/io/binary.rs
Executable file
51
core/src/io/binary.rs
Executable file
|
|
@ -0,0 +1,51 @@
|
|||
//! 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(())
|
||||
}
|
||||
118
core/src/io/export.rs
Executable file
118
core/src/io/export.rs
Executable file
|
|
@ -0,0 +1,118 @@
|
|||
//! Export pipeline — write Terrafier world to Minecraft save or image.
|
||||
|
||||
use std::path::Path;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::io::minecraft::MinecraftIOError;
|
||||
use crate::model::world::World;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ExportError {
|
||||
#[error("Minecraft I/O error: {0}")]
|
||||
MinecraftIo(#[from] MinecraftIOError),
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Render error: {0}")]
|
||||
Render(String),
|
||||
#[error("Unsupported export format: {0}")]
|
||||
UnsupportedFormat(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, ExportError>;
|
||||
|
||||
/// Export a Terrafier World to a Minecraft save directory.
|
||||
pub fn export_to_save(world: &World, output_path: &Path) -> Result<()> {
|
||||
crate::io::minecraft::save_world(world, output_path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Render a Terrafier world to a PNG image (top-down view).
|
||||
pub fn render_to_image(world: &World, output_path: &Path, scale: u32) -> Result<()> {
|
||||
// Determine bounds from tiles
|
||||
let mut min_tx = i32::MAX;
|
||||
let mut max_tx = i32::MIN;
|
||||
let mut min_tz = i32::MAX;
|
||||
let mut max_tz = i32::MIN;
|
||||
|
||||
for dim in &world.dimensions {
|
||||
for &(tx, tz) in dim.tiles.keys() {
|
||||
min_tx = min_tx.min(tx);
|
||||
max_tx = max_tx.max(tx);
|
||||
min_tz = min_tz.min(tz);
|
||||
max_tz = max_tz.max(tz);
|
||||
}
|
||||
}
|
||||
|
||||
if min_tx > max_tx || min_tz > max_tz {
|
||||
return Err(ExportError::Render("No tiles to render".to_string()));
|
||||
}
|
||||
|
||||
let tile_size = 128u32;
|
||||
let width = ((max_tx - min_tx + 1) as u32) * tile_size;
|
||||
let height = ((max_tz - min_tz + 1) as u32) * tile_size;
|
||||
|
||||
if width == 0 || height == 0 {
|
||||
return Err(ExportError::Render(
|
||||
"Empty world, nothing to render".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let img_width = (width / scale).max(1);
|
||||
let img_height = (height / scale).max(1);
|
||||
|
||||
let mut img = image::RgbImage::new(img_width, img_height);
|
||||
|
||||
// Terrain color mapping
|
||||
let terrain_colors: [image::Rgb<u8>; 7] = [
|
||||
image::Rgb([194, 178, 128]), // Desert
|
||||
image::Rgb([124, 189, 107]), // Grass
|
||||
image::Rgb([86, 140, 74]), // Forest
|
||||
image::Rgb([128, 128, 128]), // Rock
|
||||
image::Rgb([227, 212, 160]), // Sand
|
||||
image::Rgb([72, 107, 75]), // Swamp
|
||||
image::Rgb([64, 128, 255]), // Water
|
||||
];
|
||||
|
||||
for dim in &world.dimensions {
|
||||
for (&(tx, tz), tile) in &dim.tiles {
|
||||
let px = ((tx - min_tx) as u32) * tile_size;
|
||||
let pz = ((tz - min_tz) as u32) * tile_size;
|
||||
|
||||
for lx in 0..tile_size as usize {
|
||||
for lz in 0..tile_size as usize {
|
||||
let terrain_idx = tile.terrain[lz * tile_size as usize + lx] as usize;
|
||||
let color = terrain_colors[terrain_idx.min(6)];
|
||||
let sx = (px + lx as u32) / scale;
|
||||
let sy = (pz + lz as u32) / scale;
|
||||
|
||||
if sx < img_width && sy < img_height {
|
||||
let h = tile.heightmap[lz * tile_size as usize + lx];
|
||||
let height_factor = 0.7 + 0.3 * ((h + 64) as f32 / 384.0);
|
||||
let r = (color[0] as f32 * height_factor).min(255.0) as u8;
|
||||
let g = (color[1] as f32 * height_factor).min(255.0) as u8;
|
||||
let b = (color[2] as f32 * height_factor).min(255.0) as u8;
|
||||
img.put_pixel(sx, sy, image::Rgb([r, g, b]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
img.save(output_path)
|
||||
.map_err(|e| ExportError::Render(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate world is ready for export.
|
||||
pub fn validate_export(world: &World) -> Result<()> {
|
||||
if world.dimensions.is_empty() {
|
||||
return Err(ExportError::Render("World has no dimensions".to_string()));
|
||||
}
|
||||
for dim in &world.dimensions {
|
||||
if dim.tiles.is_empty() {
|
||||
log::warn!("Dimension '{}' has no tiles", dim.name);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
76
core/src/io/import.rs
Executable file
76
core/src/io/import.rs
Executable file
|
|
@ -0,0 +1,76 @@
|
|||
//! Import pipeline — load Minecraft saves or Terrafier formats into the world model.
|
||||
|
||||
use std::path::Path;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::io::minecraft::MinecraftIOError;
|
||||
use crate::model::world::World;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ImportError {
|
||||
#[error("Minecraft I/O error: {0}")]
|
||||
MinecraftIo(#[from] MinecraftIOError),
|
||||
#[error("Unsupported import format: {0}")]
|
||||
UnsupportedFormat(String),
|
||||
#[error("Import validation failed: {0}")]
|
||||
ValidationFailed(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, ImportError>;
|
||||
|
||||
/// Import a Minecraft save directory into a Terrafier World model.
|
||||
pub fn import_minecraft_save(path: &Path) -> Result<World> {
|
||||
let world = crate::io::minecraft::load_save(path)?;
|
||||
Ok(world)
|
||||
}
|
||||
|
||||
/// Validate a Minecraft save directory before importing.
|
||||
pub fn validate_save(path: &Path) -> Result<()> {
|
||||
if !path.is_dir() {
|
||||
return Err(ImportError::ValidationFailed(format!(
|
||||
"Path is not a directory: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let level_dat = path.join("level.dat");
|
||||
if !level_dat.exists() {
|
||||
return Err(ImportError::ValidationFailed(
|
||||
"Missing level.dat".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let region_dir = path.join("region");
|
||||
if !region_dir.is_dir() {
|
||||
return Err(ImportError::ValidationFailed(
|
||||
"Missing region directory".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let region_count = std::fs::read_dir(®ion_dir)
|
||||
.map_err(|e| ImportError::ValidationFailed(e.to_string()))?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().map_or(false, |ext| ext == "mca"))
|
||||
.count();
|
||||
|
||||
if region_count == 0 {
|
||||
return Err(ImportError::ValidationFailed(
|
||||
"No .mca region files found".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Import with automatic format detection.
|
||||
pub fn import(path: &Path) -> Result<World> {
|
||||
let path_str = path.display().to_string();
|
||||
|
||||
if path.is_dir() {
|
||||
if path.join("level.dat").exists() && path.join("region").is_dir() {
|
||||
return import_minecraft_save(path);
|
||||
}
|
||||
}
|
||||
|
||||
Err(ImportError::UnsupportedFormat(path_str))
|
||||
}
|
||||
561
core/src/io/minecraft.rs
Executable file
561
core/src/io/minecraft.rs
Executable file
|
|
@ -0,0 +1,561 @@
|
|||
//! Minecraft world I/O — read and write Java Edition saves.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use thiserror::Error;
|
||||
use log;
|
||||
|
||||
use terrafier_fastanvil::io::region::Region;
|
||||
use terrafier_nbt::io::reader::read_gzip;
|
||||
|
||||
use crate::model::dimension::Dimension;
|
||||
use crate::model::platform::Platform;
|
||||
use crate::model::tile::Tile;
|
||||
use crate::model::world::World;
|
||||
|
||||
#[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(®ion_dir)?;
|
||||
for entry in region_entries {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().map_or(false, |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 {
|
||||
if 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, ®ion_bytes)?;
|
||||
for (local_x, local_z) in region.chunk_coords() {
|
||||
let chunk_data = region.get_chunk_data(local_x, local_z).unwrap();
|
||||
if let Ok(chunk_tag) = read_gzip(chunk_data) {
|
||||
if 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(|a, b| b.section_y.cmp(&a.section_y));
|
||||
|
||||
for section in &sorted {
|
||||
if section.palette.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let has_blocks =
|
||||
section.palette.iter().any(|p| {
|
||||
p.get("Name").map_or(false, |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")
|
||||
.map_or(false, |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 {
|
||||
if 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<()> {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
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: BTreeMap<(i32, i32), Vec<(&(i32, i32), &Tile)>> = BTreeMap::new();
|
||||
for (key, tile) in &dim.tiles {
|
||||
let (tx, tz) = key;
|
||||
let rx = tx >> 2;
|
||||
let rz = tz >> 2;
|
||||
regions.entry((rx, rz)).or_default().push((key, tile));
|
||||
}
|
||||
|
||||
for ((rx, rz), tile_refs) in ®ions {
|
||||
let mut region = terrafier_fastanvil::io::region::Region::new(*rx, *rz);
|
||||
|
||||
for (_key, tile) in tile_refs {
|
||||
// Each tile is 128x128 blocks = 8x8 chunks
|
||||
for chunk_lx in 0..8usize {
|
||||
for chunk_lz in 0..8usize {
|
||||
let chunk_x = tile.x * 8 + chunk_lx as i32;
|
||||
let chunk_z = tile.z * 8 + chunk_lz as i32;
|
||||
|
||||
let region_local_x = (chunk_x & 31) as u8;
|
||||
let region_local_z = (chunk_z & 31) as u8;
|
||||
|
||||
let chunk_data = build_chunk_nbt(chunk_x, chunk_z, tile, chunk_lx, chunk_lz)?;
|
||||
|
||||
// Skip completely empty chunks (no blocks)
|
||||
if chunk_data.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
region.set_chunk_data(region_local_x, region_local_z, chunk_data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let region_bytes = region.to_bytes()?;
|
||||
let file_name = format!("r.{}.{}.mca", rx, rz);
|
||||
fs::write(output_path.join("region").join(&file_name), ®ion_bytes)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- Chunk building helpers ----
|
||||
|
||||
/// Minimum number of bits needed to represent values up to n-1 (clamped to MC minimum 4).
|
||||
fn bits_needed(n: usize) -> usize {
|
||||
if n <= 1 {
|
||||
return 4;
|
||||
}
|
||||
let bits = (usize::BITS - (n - 1).leading_zeros()) as usize;
|
||||
bits.max(4)
|
||||
}
|
||||
|
||||
/// Pack palette indices into a compact long array (Minecraft block state format).
|
||||
fn pack_indices(indices: &[u16], bits: usize) -> Vec<i64> {
|
||||
if indices.is_empty() || bits == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let total_bits = indices.len() * bits;
|
||||
let longs = (total_bits + 63) / 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)
|
||||
}
|
||||
|
||||
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(®ion_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().map_or(false, |e| e == "mca") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
Ok(files)
|
||||
}
|
||||
6
core/src/io/mod.rs
Executable file
6
core/src/io/mod.rs
Executable file
|
|
@ -0,0 +1,6 @@
|
|||
//! I/O module — Minecraft save import/export, rendering, and binary save/load.
|
||||
|
||||
pub mod binary;
|
||||
pub mod export;
|
||||
pub mod import;
|
||||
pub mod minecraft;
|
||||
20
core/src/lib.rs
Executable file
20
core/src/lib.rs
Executable file
|
|
@ -0,0 +1,20 @@
|
|||
//! Terrafier Core — world model, I/O, operations.
|
||||
//!
|
||||
//! The core library provides the data model (World, Dimension, Tile, Terrain),
|
||||
//! Minecraft save import/export, editing operations, and height map generation.
|
||||
|
||||
pub mod coords;
|
||||
pub mod io;
|
||||
pub mod model;
|
||||
pub mod ops;
|
||||
pub mod plugins;
|
||||
|
||||
// Re-export key types for convenience
|
||||
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::tile::Tile;
|
||||
pub use model::world::World;
|
||||
28
core/src/model/brush.rs
Executable file
28
core/src/model/brush.rs
Executable file
|
|
@ -0,0 +1,28 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
68
core/src/model/dimension.rs
Executable file
68
core/src/model/dimension.rs
Executable file
|
|
@ -0,0 +1,68 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::model::tile::Tile;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Dimension {
|
||||
pub name: String,
|
||||
pub tiles: HashMap<(i32, i32), Tile>,
|
||||
pub min_height: i16,
|
||||
pub max_height: i16,
|
||||
pub seed: u64,
|
||||
}
|
||||
|
||||
impl Serialize for Dimension {
|
||||
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
let tiles: HashMap<String, &Tile> = self
|
||||
.tiles
|
||||
.iter()
|
||||
.map(|(&(tx, tz), tile)| (format!("{},{}", tx, tz), tile))
|
||||
.collect();
|
||||
#[derive(Serialize)]
|
||||
struct Dim<'a> {
|
||||
name: &'a str,
|
||||
tiles: HashMap<String, &'a Tile>,
|
||||
min_height: i16,
|
||||
max_height: i16,
|
||||
seed: u64,
|
||||
}
|
||||
Dim {
|
||||
name: &self.name,
|
||||
tiles,
|
||||
min_height: self.min_height,
|
||||
max_height: self.max_height,
|
||||
seed: self.seed,
|
||||
}
|
||||
.serialize(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Dimension {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
#[derive(Deserialize)]
|
||||
struct Dim {
|
||||
name: String,
|
||||
tiles: HashMap<String, Tile>,
|
||||
min_height: i16,
|
||||
max_height: i16,
|
||||
seed: u64,
|
||||
}
|
||||
let dim = Dim::deserialize(d)?;
|
||||
let mut tiles = HashMap::with_capacity(dim.tiles.len());
|
||||
for (key, tile) in dim.tiles {
|
||||
if let Some((tx_s, tz_s)) = key.split_once(',') {
|
||||
if let (Ok(tx), Ok(tz)) = (tx_s.parse::<i32>(), tz_s.parse::<i32>()) {
|
||||
tiles.insert((tx, tz), tile);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
name: dim.name,
|
||||
tiles,
|
||||
min_height: dim.min_height,
|
||||
max_height: dim.max_height,
|
||||
seed: dim.seed,
|
||||
})
|
||||
}
|
||||
}
|
||||
13
core/src/model/layers.rs
Executable file
13
core/src/model/layers.rs
Executable file
|
|
@ -0,0 +1,13 @@
|
|||
pub trait Layer: Send + Sync {
|
||||
fn id(&self) -> &'static str;
|
||||
fn name(&self) -> &'static str;
|
||||
fn data_size(&self) -> DataSize;
|
||||
fn priority(&self) -> i32;
|
||||
}
|
||||
|
||||
pub enum DataSize {
|
||||
Bit,
|
||||
Nibble,
|
||||
Byte,
|
||||
Int,
|
||||
}
|
||||
17
core/src/model/mod.rs
Executable file
17
core/src/model/mod.rs
Executable file
|
|
@ -0,0 +1,17 @@
|
|||
//! World model — the core data structures.
|
||||
//!
|
||||
//! 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 world;
|
||||
|
||||
pub use dimension::Dimension;
|
||||
pub use platform::Platform;
|
||||
pub use terrain::Terrain;
|
||||
pub use tile::Tile;
|
||||
pub use world::World;
|
||||
20
core/src/model/platform.rs
Executable file
20
core/src/model/platform.rs
Executable file
|
|
@ -0,0 +1,20 @@
|
|||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
26
core/src/model/terrain.rs
Executable file
26
core/src/model/terrain.rs
Executable file
|
|
@ -0,0 +1,26 @@
|
|||
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",
|
||||
}
|
||||
}
|
||||
}
|
||||
62
core/src/model/tile.rs
Executable file
62
core/src/model/tile.rs
Executable file
|
|
@ -0,0 +1,62 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde_big_array::BigArray;
|
||||
|
||||
pub use crate::coords::{TILE_SIZE, TILE_SIZE_BITS};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Tile {
|
||||
pub x: i32,
|
||||
pub z: i32,
|
||||
pub min_height: i16,
|
||||
pub max_height: i16,
|
||||
#[serde(with = "BigArray")]
|
||||
pub heightmap: [i16; 16384],
|
||||
#[serde(with = "BigArray")]
|
||||
pub terrain: [u8; 16384],
|
||||
#[serde(with = "BigArray")]
|
||||
pub water_level: [u8; 16384],
|
||||
pub layer_data: std::collections::HashMap<u32, LayerBuffer>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum LayerBuffer {
|
||||
Bit(Vec<u64>),
|
||||
Nibble(Vec<u8>),
|
||||
Byte(Vec<u8>),
|
||||
Int(Vec<i32>),
|
||||
}
|
||||
|
||||
impl Tile {
|
||||
pub fn new(x: i32, z: i32, min_height: i16, max_height: i16) -> Self {
|
||||
Self {
|
||||
x,
|
||||
z,
|
||||
min_height,
|
||||
max_height,
|
||||
heightmap: [0i16; 16384],
|
||||
terrain: [0u8; 16384],
|
||||
water_level: [0u8; 16384],
|
||||
layer_data: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get height at local tile coordinates.
|
||||
pub fn get_height(&self, lx: usize, lz: usize) -> i16 {
|
||||
self.heightmap[lz * TILE_SIZE + lx]
|
||||
}
|
||||
|
||||
/// Set height at local tile coordinates.
|
||||
pub fn set_height(&mut self, lx: usize, lz: usize, height: i16) {
|
||||
self.heightmap[lz * TILE_SIZE + lx] = height;
|
||||
}
|
||||
|
||||
/// Get terrain type at local tile coordinates.
|
||||
pub fn get_terrain(&self, lx: usize, lz: usize) -> u8 {
|
||||
self.terrain[lz * TILE_SIZE + lx]
|
||||
}
|
||||
|
||||
/// Set terrain type at local tile coordinates.
|
||||
pub fn set_terrain(&mut self, lx: usize, lz: usize, terrain: u8) {
|
||||
self.terrain[lz * TILE_SIZE + lx] = terrain;
|
||||
}
|
||||
}
|
||||
68
core/src/model/world.rs
Executable file
68
core/src/model/world.rs
Executable file
|
|
@ -0,0 +1,68 @@
|
|||
use rayon::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::model::dimension::Dimension;
|
||||
use crate::model::platform::Platform;
|
||||
use crate::ops::heightmap::{HeightMapSource, NoiseHeightMap};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct World {
|
||||
pub name: String,
|
||||
pub platform: Platform,
|
||||
pub dimensions: Vec<Dimension>,
|
||||
pub seed: u64,
|
||||
}
|
||||
|
||||
impl World {
|
||||
/// Create a new world with a single overworld dimension and generated terrain.
|
||||
pub fn new(name: &str, seed: u64) -> 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();
|
||||
|
||||
let tiles: Vec<(i32, i32, crate::model::tile::Tile)> = coords
|
||||
.par_iter()
|
||||
.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(
|
||||
&mut tile,
|
||||
seed.wrapping_add(
|
||||
(tx.wrapping_mul(374_761_393) as u64)
|
||||
.wrapping_add((tz as u64).wrapping_mul(668_265_263)),
|
||||
),
|
||||
);
|
||||
(tx, tz, tile)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut dim_tiles = std::collections::HashMap::with_capacity(tiles.len());
|
||||
for (tx, tz, tile) in tiles {
|
||||
dim_tiles.insert((tx, tz), tile);
|
||||
}
|
||||
|
||||
let dimension = Dimension {
|
||||
name: "overworld".to_string(),
|
||||
tiles: dim_tiles,
|
||||
min_height: platform.min_height,
|
||||
max_height: platform.max_height,
|
||||
seed,
|
||||
};
|
||||
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
platform,
|
||||
dimensions: vec![dimension],
|
||||
seed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get mutable reference to the overworld dimension.
|
||||
pub fn overworld_mut(&mut self) -> Option<&mut Dimension> {
|
||||
self.dimensions.iter_mut().find(|d| d.name == "overworld")
|
||||
}
|
||||
}
|
||||
128
core/src/ops/heightmap.rs
Executable file
128
core/src/ops/heightmap.rs
Executable file
|
|
@ -0,0 +1,128 @@
|
|||
//! Height map sources — generate height maps from noise, flat, or combined sources.
|
||||
|
||||
use crate::model::terrain::Terrain;
|
||||
use crate::model::tile::Tile;
|
||||
|
||||
/// A source that can generate a height map for a tile.
|
||||
pub trait HeightMapSource: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
fn generate(&self, tile: &mut Tile, seed: u64);
|
||||
}
|
||||
|
||||
/// Flat height map — sets all heights to a constant value.
|
||||
pub struct FlatHeightMap {
|
||||
pub height: i16,
|
||||
}
|
||||
|
||||
impl HeightMapSource for FlatHeightMap {
|
||||
fn name(&self) -> &'static str {
|
||||
"Flat"
|
||||
}
|
||||
|
||||
fn generate(&self, tile: &mut Tile, _seed: u64) {
|
||||
for h in tile.heightmap.iter_mut() {
|
||||
*h = self.height;
|
||||
}
|
||||
for t in tile.terrain.iter_mut() {
|
||||
*t = Terrain::Grass as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Noise-based height map using Simplex noise.
|
||||
pub struct NoiseHeightMap {
|
||||
pub base_height: f64,
|
||||
pub amplitude: f64,
|
||||
pub frequency: f64,
|
||||
pub scale_x: f64,
|
||||
pub scale_z: f64,
|
||||
}
|
||||
|
||||
impl Default for NoiseHeightMap {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base_height: 63.0,
|
||||
amplitude: 30.0,
|
||||
frequency: 0.01,
|
||||
scale_x: 1.0,
|
||||
scale_z: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HeightMapSource for NoiseHeightMap {
|
||||
fn name(&self) -> &'static str {
|
||||
"Noise"
|
||||
}
|
||||
|
||||
fn generate(&self, tile: &mut Tile, seed: u64) {
|
||||
use terrafier_noise::NoiseFn;
|
||||
let seed_u32 = (seed ^ (seed >> 32)) as u32;
|
||||
let noise_gen = terrafier_noise::OpenSimplex::new(seed_u32);
|
||||
let tile_size = 128usize;
|
||||
|
||||
for lx in 0..tile_size {
|
||||
for lz in 0..tile_size {
|
||||
let world_x = (tile.x as f64 * tile_size as f64 + lx as f64) * self.scale_x;
|
||||
let world_z = (tile.z as f64 * tile_size as f64 + lz as f64) * self.scale_z;
|
||||
|
||||
let n = noise_gen.get([world_x * self.frequency, world_z * self.frequency]);
|
||||
let height = (self.base_height + n as f64 * self.amplitude)
|
||||
.round()
|
||||
.clamp(tile.min_height as f64, tile.max_height as f64)
|
||||
as i16;
|
||||
|
||||
let idx = lz * tile_size + lx;
|
||||
tile.heightmap[idx] = height;
|
||||
|
||||
tile.terrain[idx] = if height < 0 {
|
||||
Terrain::Water as u8
|
||||
} else if height < 5 {
|
||||
Terrain::Sand as u8
|
||||
} else if height < 10 {
|
||||
Terrain::Grass as u8
|
||||
} else if height < 20 {
|
||||
Terrain::Forest as u8
|
||||
} else {
|
||||
Terrain::Rock as u8
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Combined height map — mix several height map sources.
|
||||
pub struct CombinedHeightMap {
|
||||
pub sources: Vec<(Box<dyn HeightMapSource>, f64)>,
|
||||
}
|
||||
|
||||
impl HeightMapSource for CombinedHeightMap {
|
||||
fn name(&self) -> &'static str {
|
||||
"Combined"
|
||||
}
|
||||
|
||||
fn generate(&self, tile: &mut Tile, seed: u64) {
|
||||
let mut accumulated_heights = vec![0.0f64; 16384];
|
||||
|
||||
for (source, weight) in &self.sources {
|
||||
let mut temp_tile = Tile::new(tile.x, tile.z, tile.min_height, tile.max_height);
|
||||
source.generate(&mut temp_tile, seed.wrapping_add(*weight as u64 * 100));
|
||||
|
||||
for i in 0..16384 {
|
||||
accumulated_heights[i] += temp_tile.heightmap[i] as f64 * weight;
|
||||
}
|
||||
}
|
||||
|
||||
let weight_sum: f64 = self.sources.iter().map(|(_, w)| w).sum();
|
||||
|
||||
if weight_sum > 0.0 {
|
||||
for i in 0..16384 {
|
||||
let h = (accumulated_heights[i] / weight_sum)
|
||||
.round()
|
||||
.clamp(tile.min_height as f64, tile.max_height as f64)
|
||||
as i16;
|
||||
tile.heightmap[i] = h;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
4
core/src/ops/mod.rs
Executable file
4
core/src/ops/mod.rs
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
//! Editing operations — terrain modification, brush operations, height maps.
|
||||
|
||||
pub mod heightmap;
|
||||
pub mod operations;
|
||||
492
core/src/ops/operations.rs
Executable file
492
core/src/ops/operations.rs
Executable file
|
|
@ -0,0 +1,492 @@
|
|||
//! Editing operations — raise, lower, smooth, flatten, etc.
|
||||
//!
|
||||
//! Each operation implements the Operation trait and supports undo.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::Arc;
|
||||
|
||||
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] as i16 + 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
11
core/src/plugins/export_plugin.rs
Executable file
11
core/src/plugins/export_plugin.rs
Executable file
|
|
@ -0,0 +1,11 @@
|
|||
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;
|
||||
}
|
||||
8
core/src/plugins/layer_plugin.rs
Executable file
8
core/src/plugins/layer_plugin.rs
Executable file
|
|
@ -0,0 +1,8 @@
|
|||
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>>;
|
||||
}
|
||||
16
core/src/plugins/mod.rs
Executable file
16
core/src/plugins/mod.rs
Executable file
|
|
@ -0,0 +1,16 @@
|
|||
//! Plugin host — trait-объекты для расширения функциональности.
|
||||
//!
|
||||
//! Плагины позволяют добавлять custom слои, операции редактирования,
|
||||
//! форматы экспорта и источники генерации, не меняя ядро.
|
||||
|
||||
pub mod export_plugin;
|
||||
pub mod layer_plugin;
|
||||
pub mod operation_plugin;
|
||||
pub mod registry;
|
||||
pub mod source_plugin;
|
||||
|
||||
pub use export_plugin::ExportPlugin;
|
||||
pub use layer_plugin::LayerPlugin;
|
||||
pub use operation_plugin::OperationPlugin;
|
||||
pub use registry::PluginRegistry;
|
||||
pub use source_plugin::TileSourcePlugin;
|
||||
8
core/src/plugins/operation_plugin.rs
Executable file
8
core/src/plugins/operation_plugin.rs
Executable file
|
|
@ -0,0 +1,8 @@
|
|||
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>>>;
|
||||
}
|
||||
73
core/src/plugins/registry.rs
Executable file
73
core/src/plugins/registry.rs
Executable file
|
|
@ -0,0 +1,73 @@
|
|||
use super::{ExportPlugin, LayerPlugin, OperationPlugin, TileSourcePlugin};
|
||||
|
||||
/// Central registry for all plugins.
|
||||
pub struct PluginRegistry {
|
||||
pub layers: Vec<Box<dyn LayerPlugin>>,
|
||||
pub operations: Vec<Box<dyn OperationPlugin>>,
|
||||
pub exports: Vec<Box<dyn ExportPlugin>>,
|
||||
pub sources: Vec<Box<dyn TileSourcePlugin>>,
|
||||
}
|
||||
|
||||
impl PluginRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
layers: Vec::new(),
|
||||
operations: Vec::new(),
|
||||
exports: Vec::new(),
|
||||
sources: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_layer(&mut self, plugin: Box<dyn LayerPlugin>) {
|
||||
self.layers.push(plugin);
|
||||
}
|
||||
|
||||
pub fn register_operation(&mut self, plugin: Box<dyn OperationPlugin>) {
|
||||
self.operations.push(plugin);
|
||||
}
|
||||
|
||||
pub fn register_export(&mut self, plugin: Box<dyn ExportPlugin>) {
|
||||
self.exports.push(plugin);
|
||||
}
|
||||
|
||||
pub fn register_source(&mut self, plugin: Box<dyn TileSourcePlugin>) {
|
||||
self.sources.push(plugin);
|
||||
}
|
||||
|
||||
pub fn all_plugin_names(&self) -> Vec<String> {
|
||||
let mut names = Vec::new();
|
||||
for p in &self.layers {
|
||||
names.push(p.name().to_string());
|
||||
}
|
||||
for p in &self.operations {
|
||||
names.push(p.name().to_string());
|
||||
}
|
||||
for p in &self.exports {
|
||||
names.push(p.name().to_string());
|
||||
}
|
||||
for p in &self.sources {
|
||||
names.push(p.name().to_string());
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Find a layer plugin by name.
|
||||
pub fn find_layer_by_name(&self, name: &str) -> Option<&Box<dyn LayerPlugin>> {
|
||||
self.layers.iter().find(|p| p.name() == name)
|
||||
}
|
||||
|
||||
/// Find an operation plugin by name.
|
||||
pub fn find_operation_by_name(&self, name: &str) -> Option<&Box<dyn OperationPlugin>> {
|
||||
self.operations.iter().find(|p| p.name() == name)
|
||||
}
|
||||
|
||||
/// Find an export plugin by name.
|
||||
pub fn find_export_by_name(&self, name: &str) -> Option<&Box<dyn ExportPlugin>> {
|
||||
self.exports.iter().find(|p| p.name() == name)
|
||||
}
|
||||
|
||||
/// Find a tile source plugin by name.
|
||||
pub fn find_source_by_name(&self, name: &str) -> Option<&Box<dyn TileSourcePlugin>> {
|
||||
self.sources.iter().find(|p| p.name() == name)
|
||||
}
|
||||
}
|
||||
8
core/src/plugins/source_plugin.rs
Executable file
8
core/src/plugins/source_plugin.rs
Executable file
|
|
@ -0,0 +1,8 @@
|
|||
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);
|
||||
}
|
||||
215
core/tests/golden_test.rs
Executable file
215
core/tests/golden_test.rs
Executable file
|
|
@ -0,0 +1,215 @@
|
|||
//! Golden integration test for loading a synthetic Minecraft .mca region file.
|
||||
//!
|
||||
//! This test creates a minimal valid Anvil world save in a temporary directory,
|
||||
//! loads it via `load_save`, and verifies the parsed chunk data is correct.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use terrafier_core::io::minecraft::load_save;
|
||||
use terrafier_fastanvil::io::chunk::Chunk;
|
||||
use terrafier_fastanvil::io::region::Region;
|
||||
use terrafier_nbt::io::reader::read_gzip;
|
||||
use terrafier_nbt::io::writer::to_gzip_bytes;
|
||||
use terrafier_nbt::Tag;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers: synthetic NBT and region file writer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Create a minimal level.dat as gzip-compressed NBT bytes.
|
||||
fn make_level_dat() -> Vec<u8> {
|
||||
let mut data = HashMap::new();
|
||||
data.insert("DataVersion".into(), Tag::Int(3954));
|
||||
data.insert("LevelName".into(), Tag::String("test_world".into()));
|
||||
|
||||
let mut wgs = HashMap::new();
|
||||
wgs.insert("seed".into(), Tag::Long(42));
|
||||
data.insert("WorldGenSettings".into(), Tag::Compound(wgs));
|
||||
|
||||
let mut root = HashMap::new();
|
||||
root.insert("Data".into(), Tag::Compound(data));
|
||||
|
||||
to_gzip_bytes(&Tag::Compound(root)).unwrap()
|
||||
}
|
||||
|
||||
/// Build a minimal valid chunk NBT tag, gzip-compress it, and return the
|
||||
/// gzip-compressed bytes ready for storage in an .mca file.
|
||||
fn make_chunk_nbt_gzip(cx: i32, cz: i32) -> Vec<u8> {
|
||||
let mut chunk = HashMap::new();
|
||||
chunk.insert("xPos".into(), Tag::Int(cx));
|
||||
chunk.insert("zPos".into(), Tag::Int(cz));
|
||||
chunk.insert("DataVersion".into(), Tag::Int(3954));
|
||||
chunk.insert("Status".into(), Tag::String("full".into()));
|
||||
|
||||
// Single section at Y=4 (blocks y = 64..79)
|
||||
let mut section = HashMap::new();
|
||||
section.insert("Y".into(), Tag::Byte(4));
|
||||
|
||||
// block_states with a single-entry palette
|
||||
let mut palette_list: Vec<Tag> = Vec::new();
|
||||
let mut grass = HashMap::new();
|
||||
grass.insert(
|
||||
"Name".into(),
|
||||
Tag::String("minecraft:grass_block".into()),
|
||||
);
|
||||
palette_list.push(Tag::Compound(grass));
|
||||
let mut block_states = HashMap::new();
|
||||
block_states.insert("palette".into(), Tag::List(palette_list));
|
||||
section.insert("block_states".into(), Tag::Compound(block_states));
|
||||
|
||||
// biomes with a single-entry palette
|
||||
let mut biome_palette: Vec<Tag> = Vec::new();
|
||||
let mut plains = HashMap::new();
|
||||
plains.insert("Name".into(), Tag::String("minecraft:plains".into()));
|
||||
biome_palette.push(Tag::Compound(plains));
|
||||
let mut biomes = HashMap::new();
|
||||
biomes.insert("palette".into(), Tag::List(biome_palette));
|
||||
section.insert("biomes".into(), Tag::Compound(biomes));
|
||||
|
||||
let mut sections = Vec::new();
|
||||
sections.push(Tag::Compound(section));
|
||||
chunk.insert("sections".into(), Tag::List(sections));
|
||||
|
||||
to_gzip_bytes(&Tag::Compound(chunk)).unwrap()
|
||||
}
|
||||
|
||||
/// Build raw .mca region file bytes containing a single chunk at global
|
||||
/// coordinates (cx, cz).
|
||||
///
|
||||
/// The chunk payload is stored as *uncompressed* (compression type 3), so
|
||||
/// `Region::from_bytes` treats it as a pass-through and `load_save`'s
|
||||
/// subsequent `read_gzip` call can decompress it.
|
||||
fn build_region_bytes(cx: i32, cz: i32, chunk_gzip: &[u8]) -> Vec<u8> {
|
||||
let sector_size: u64 = 4096;
|
||||
|
||||
// .mca chunk entry: [4B total_len][1B compression_type][payload…]
|
||||
let total_len = 1 + chunk_gzip.len();
|
||||
let mut chunk_entry = Vec::with_capacity(4 + total_len);
|
||||
chunk_entry.extend_from_slice(&(total_len as u32).to_be_bytes());
|
||||
chunk_entry.push(3); // compression type = Uncompressed
|
||||
chunk_entry.extend_from_slice(chunk_gzip);
|
||||
|
||||
// Pad to sector boundary
|
||||
while chunk_entry.len() % sector_size as usize != 0 {
|
||||
chunk_entry.push(0);
|
||||
}
|
||||
|
||||
let sectors_needed = (chunk_entry.len() / sector_size as usize) as u32;
|
||||
|
||||
// Header tables (1024 entries each)
|
||||
let mut locations = [0u32; 1024];
|
||||
let mut timestamps = [0u32; 1024];
|
||||
|
||||
let local_x = cx.rem_euclid(32) as usize;
|
||||
let local_z = cz.rem_euclid(32) as usize;
|
||||
let index = local_z * 32 + local_x;
|
||||
|
||||
let offset: u32 = 2; // first sector after the 2-sector header
|
||||
locations[index] = (offset << 8) | (sectors_needed & 0xFF);
|
||||
timestamps[index] = 1;
|
||||
|
||||
// Assemble output: 2 sectors of header + chunk data
|
||||
let mut output = Vec::new();
|
||||
|
||||
for loc in locations.iter() {
|
||||
output.extend_from_slice(&loc.to_be_bytes());
|
||||
}
|
||||
for ts in timestamps.iter() {
|
||||
output.extend_from_slice(&ts.to_be_bytes());
|
||||
}
|
||||
while output.len() < 2 * sector_size as usize {
|
||||
output.push(0);
|
||||
}
|
||||
|
||||
output.extend_from_slice(&chunk_entry);
|
||||
output
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_golden_mca_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let world_path = dir.path().join("test_world");
|
||||
std::fs::create_dir_all(world_path.join("region")).unwrap();
|
||||
|
||||
// Write level.dat
|
||||
std::fs::write(world_path.join("level.dat"), &make_level_dat()).unwrap();
|
||||
|
||||
// Build and write region r.0.0.mca
|
||||
let chunk_gzip = make_chunk_nbt_gzip(0, 0);
|
||||
let region_bytes = build_region_bytes(0, 0, &chunk_gzip);
|
||||
std::fs::write(world_path.join("region/r.0.0.mca"), ®ion_bytes).unwrap();
|
||||
|
||||
// --- 1. load_save integration ---
|
||||
let world = load_save(&world_path).expect("load_save should succeed");
|
||||
|
||||
assert_eq!(world.name, "test_world");
|
||||
assert_eq!(world.seed, 42);
|
||||
assert_eq!(world.dimensions.len(), 1, "should have one dimension");
|
||||
|
||||
let dim = &world.dimensions[0];
|
||||
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.
|
||||
|
||||
// --- 2. Direct region + chunk parsing verification ---
|
||||
let region = Region::from_bytes(0, 0, ®ion_bytes).expect("region should parse");
|
||||
assert_eq!(region.chunk_count(), 1, "region should contain 1 chunk");
|
||||
let coords = region.chunk_coords();
|
||||
assert_eq!(coords, vec![(0, 0)]);
|
||||
|
||||
let raw = region.get_chunk_data(0, 0).expect("chunk (0,0) should exist");
|
||||
assert!(!raw.is_empty(), "chunk data should not be empty");
|
||||
|
||||
// The stored payload is gzip-compressed NBT → decompress via read_gzip
|
||||
let chunk_tag = read_gzip(raw).expect("chunk data should be valid gzip NBT");
|
||||
let chunk = Chunk::from_nbt(&chunk_tag).expect("chunk NBT should be parseable");
|
||||
|
||||
// --- 3. Field-level assertions ---
|
||||
assert_eq!(chunk.x, 0, "chunk xPos");
|
||||
assert_eq!(chunk.z, 0, "chunk zPos");
|
||||
assert_eq!(chunk.data_version, 3954, "chunk DataVersion");
|
||||
assert_eq!(
|
||||
chunk.status.as_deref(),
|
||||
Some("full"),
|
||||
"chunk Status"
|
||||
);
|
||||
|
||||
// Sections
|
||||
assert_eq!(chunk.sections.len(), 1, "should have 1 section");
|
||||
let section = &chunk.sections[0];
|
||||
assert_eq!(section.section_y, 4, "section Y");
|
||||
|
||||
// Block palette
|
||||
assert_eq!(section.palette.len(), 1, "block palette size");
|
||||
let block_name = section.palette[0]
|
||||
.get("Name")
|
||||
.and_then(|t| match t {
|
||||
Tag::String(s) => Some(s.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("block palette entry should have Name");
|
||||
assert_eq!(block_name, "minecraft:grass_block");
|
||||
|
||||
// Biome palette
|
||||
assert_eq!(section.biome_palette.len(), 1, "biome palette size");
|
||||
let biome_name = section.biome_palette[0]
|
||||
.get("Name")
|
||||
.and_then(|t| match t {
|
||||
Tag::String(s) => Some(s.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("biome palette entry should have Name");
|
||||
assert_eq!(biome_name, "minecraft:plains");
|
||||
|
||||
// --- 4. Region round-trip via to_bytes / from_bytes ---
|
||||
let reencoded = region.to_bytes().expect("region should re-serialize");
|
||||
let region2 = Region::from_bytes(0, 0, &reencoded).expect("re-serialized region should parse");
|
||||
assert_eq!(region2.chunk_count(), 1, "round-tripped region should still have 1 chunk");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue