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:
parent
3d3798136d
commit
bdd4564240
104 changed files with 5184 additions and 3912 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -17,5 +17,9 @@ Thumbs.db
|
|||
*.mca
|
||||
*.mcr
|
||||
|
||||
# Agent
|
||||
AGENTS.md
|
||||
.opencode/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
|
|
|||
40
Cargo.lock
generated
40
Cargo.lock
generated
|
|
@ -1110,6 +1110,7 @@ dependencies = [
|
|||
"objc2-foundation 0.2.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"pollster",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"static_assertions",
|
||||
|
|
@ -1117,6 +1118,7 @@ dependencies = [
|
|||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"web-time",
|
||||
"wgpu",
|
||||
"winapi",
|
||||
"windows-sys 0.59.0",
|
||||
"winit",
|
||||
|
|
@ -1661,6 +1663,18 @@ dependencies = [
|
|||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpu-allocator"
|
||||
version = "0.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd"
|
||||
dependencies = [
|
||||
"log",
|
||||
"presser",
|
||||
"thiserror 1.0.69",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpu-descriptor"
|
||||
version = "0.3.2"
|
||||
|
|
@ -2831,6 +2845,12 @@ dependencies = [
|
|||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "presser"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
|
|
@ -3026,6 +3046,12 @@ dependencies = [
|
|||
"rand_core 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "range-alloc"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
|
|
@ -3578,9 +3604,11 @@ dependencies = [
|
|||
"serde",
|
||||
"serde-big-array",
|
||||
"tempfile",
|
||||
"terrafier-biome-db",
|
||||
"terrafier-fastanvil",
|
||||
"terrafier-nbt",
|
||||
"terrafier-noise",
|
||||
"terrafier-palette-compress",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
|
|
@ -3601,10 +3629,12 @@ dependencies = [
|
|||
"anyhow",
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
"env_logger",
|
||||
"log",
|
||||
"rfd",
|
||||
"terrafier-core",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3810,9 +3840,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.0"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
|
|
@ -4221,6 +4251,7 @@ dependencies = [
|
|||
"document-features",
|
||||
"js-sys",
|
||||
"log",
|
||||
"naga",
|
||||
"parking_lot",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
|
|
@ -4268,13 +4299,16 @@ dependencies = [
|
|||
"android_system_properties",
|
||||
"arrayvec",
|
||||
"ash",
|
||||
"bit-set",
|
||||
"bitflags 2.11.1",
|
||||
"block",
|
||||
"bytemuck",
|
||||
"cfg_aliases",
|
||||
"core-graphics-types",
|
||||
"glow",
|
||||
"glutin_wgl_sys",
|
||||
"gpu-alloc",
|
||||
"gpu-allocator",
|
||||
"gpu-descriptor",
|
||||
"js-sys",
|
||||
"khronos-egl",
|
||||
|
|
@ -4289,6 +4323,7 @@ dependencies = [
|
|||
"ordered-float",
|
||||
"parking_lot",
|
||||
"profiling",
|
||||
"range-alloc",
|
||||
"raw-window-handle",
|
||||
"renderdoc-sys",
|
||||
"rustc-hash 1.1.0",
|
||||
|
|
@ -4298,6 +4333,7 @@ dependencies = [
|
|||
"web-sys",
|
||||
"wgpu-types",
|
||||
"windows",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@ use std::sync::Arc;
|
|||
use criterion::{Criterion, black_box, criterion_group, criterion_main};
|
||||
|
||||
use terrafier_core::io::export::render_to_image;
|
||||
use terrafier_core::model::brush::SymmetricBrush;
|
||||
use terrafier_core::model::terrain::Terrain;
|
||||
use terrafier_core::model::types::{SymmetricBrush, Terrain};
|
||||
use terrafier_core::model::world::World;
|
||||
use terrafier_core::ops::operations::{HeightOperation, Operation, PaintOperation};
|
||||
|
||||
|
|
|
|||
3
cli/src/cmds/io/mod.rs
Normal file
3
cli/src/cmds/io/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod export;
|
||||
pub mod import;
|
||||
pub mod render;
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
pub mod export;
|
||||
pub mod import;
|
||||
mod io;
|
||||
pub use io::export;
|
||||
pub use io::import;
|
||||
pub use io::render;
|
||||
|
||||
pub mod info;
|
||||
pub mod new;
|
||||
pub mod render;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
106
core/src/io/layer_export/biome_res.rs
Normal file
106
core/src/io/layer_export/biome_res.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
85
core/src/io/layer_export/caves_frost.rs
Normal file
85
core/src/io/layer_export/caves_frost.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
66
core/src/io/layer_export/mod.rs
Normal file
66
core/src/io/layer_export/mod.rs
Normal 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};
|
||||
86
core/src/io/layer_export/river_trees.rs
Normal file
86
core/src/io/layer_export/river_trees.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(®ion_dir)?;
|
||||
for entry in region_entries {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|e| e == "mca") {
|
||||
let file_name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
||||
let parts: Vec<&str> = file_name.split('.').collect();
|
||||
if parts.len() >= 3
|
||||
&& let (Ok(rx), Ok(rz)) = (parts[1].parse::<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)
|
||||
&& let Some(chunk) =
|
||||
terrafier_fastanvil::io::chunk::Chunk::from_nbt(&chunk_tag)
|
||||
{
|
||||
let tile_x = chunk.x >> 3;
|
||||
let tile_z = chunk.z >> 3;
|
||||
|
||||
// 8 chunks per tile (128 blocks / 16 blocks per chunk)
|
||||
let chunk_local_x = (chunk.x & 7) as usize;
|
||||
let chunk_local_z = (chunk.z & 7) as usize;
|
||||
|
||||
let tile = tiles.entry((tile_x, tile_z)).or_insert_with(|| {
|
||||
Tile::new(tile_x, tile_z, platform.min_height, platform.max_height)
|
||||
});
|
||||
|
||||
for lx in 0..16usize {
|
||||
for lz in 0..16usize {
|
||||
let mut surface_y = None;
|
||||
|
||||
if !chunk.sections.is_empty() {
|
||||
let mut sorted: Vec<_> = chunk.sections.iter().collect();
|
||||
sorted.sort_by_key(|b| std::cmp::Reverse(b.section_y));
|
||||
|
||||
for section in &sorted {
|
||||
if section.palette.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let has_blocks = section.palette.iter().any(|p| {
|
||||
p.get("Name").is_some_and(|n| {
|
||||
matches!(
|
||||
n,
|
||||
terrafier_nbt::Tag::String(s)
|
||||
if s != "minecraft:air"
|
||||
)
|
||||
})
|
||||
});
|
||||
|
||||
if !has_blocks {
|
||||
continue;
|
||||
}
|
||||
|
||||
if section.block_data.is_empty() {
|
||||
if section.palette[0].get("Name").is_some_and(|n| {
|
||||
matches!(
|
||||
n,
|
||||
terrafier_nbt::Tag::String(s)
|
||||
if s == "minecraft:air"
|
||||
)
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
surface_y = Some((section.section_y as i32) * 16 + 15);
|
||||
break;
|
||||
}
|
||||
|
||||
surface_y = Some((section.section_y as i32) * 16 + 15);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let tile_local_x = chunk_local_x * 16 + lx;
|
||||
let tile_local_z = chunk_local_z * 16 + lz;
|
||||
|
||||
if tile_local_x < 128
|
||||
&& tile_local_z < 128
|
||||
&& let Some(y) = surface_y
|
||||
{
|
||||
let clamped =
|
||||
(y as i16).clamp(tile.min_height, tile.max_height);
|
||||
tile.heightmap[tile_local_z * 128 + tile_local_x] = clamped;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let dimension = Dimension {
|
||||
name: "overworld".to_string(),
|
||||
tiles,
|
||||
min_height: platform.min_height,
|
||||
max_height: platform.max_height,
|
||||
seed,
|
||||
};
|
||||
|
||||
Ok(World {
|
||||
name: world_name,
|
||||
platform,
|
||||
dimensions: vec![dimension],
|
||||
seed,
|
||||
})
|
||||
}
|
||||
|
||||
/// Save a Terrafier World to a Minecraft save directory.
|
||||
pub fn save_world(world: &World, output_path: &Path) -> Result<()> {
|
||||
fs::create_dir_all(output_path.join("region"))?;
|
||||
|
||||
// Write level.dat
|
||||
let level_tag = build_level_dat(world)?;
|
||||
let level_bytes = terrafier_nbt::io::writer::to_gzip_bytes(&level_tag)?;
|
||||
fs::write(output_path.join("level.dat"), &level_bytes)?;
|
||||
|
||||
let dim = match world.dimensions.first() {
|
||||
Some(d) => d,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
// Group tiles by region (512×512 blocks = 4×4 tiles of 128×128 each)
|
||||
let mut regions: RegionTiles = BTreeMap::new();
|
||||
for (key, tile) in &dim.tiles {
|
||||
let (tx, tz) = key;
|
||||
let rx = tx >> 2;
|
||||
let rz = tz >> 2;
|
||||
regions.entry((rx, rz)).or_default().push((key, tile));
|
||||
}
|
||||
|
||||
for ((rx, rz), tile_refs) in ®ions {
|
||||
let mut region = terrafier_fastanvil::io::region::Region::new(*rx, *rz);
|
||||
|
||||
for (_key, tile) in tile_refs {
|
||||
// Each tile is 128x128 blocks = 8x8 chunks
|
||||
for chunk_lx in 0..8usize {
|
||||
for chunk_lz in 0..8usize {
|
||||
let chunk_x = tile.x * 8 + chunk_lx as i32;
|
||||
let chunk_z = tile.z * 8 + chunk_lz as i32;
|
||||
|
||||
let region_local_x = (chunk_x & 31) as u8;
|
||||
let region_local_z = (chunk_z & 31) as u8;
|
||||
|
||||
let chunk_data = build_chunk_nbt(chunk_x, chunk_z, tile, chunk_lx, chunk_lz)?;
|
||||
|
||||
// Skip completely empty chunks (no blocks)
|
||||
if chunk_data.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
region.set_chunk_data(region_local_x, region_local_z, chunk_data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let region_bytes = region.to_bytes()?;
|
||||
let file_name = format!("r.{}.{}.mca", rx, rz);
|
||||
fs::write(output_path.join("region").join(&file_name), ®ion_bytes)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- Chunk building helpers ----
|
||||
|
||||
/// Minimum number of bits needed to represent values up to n-1 (clamped to MC minimum 4).
|
||||
fn bits_needed(n: usize) -> usize {
|
||||
if n <= 1 {
|
||||
return 4;
|
||||
}
|
||||
let bits = (usize::BITS - (n - 1).leading_zeros()) as usize;
|
||||
bits.max(4)
|
||||
}
|
||||
|
||||
/// Pack palette indices into a compact long array (Minecraft block state format).
|
||||
fn pack_indices(indices: &[u16], bits: usize) -> Vec<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 ®ions {
|
||||
let mut region = terrafier_fastanvil::io::region::Region::new(*rx, *rz);
|
||||
|
||||
for (_key, tile) in tile_refs {
|
||||
for chunk_lx in 0..8usize {
|
||||
for chunk_lz in 0..8usize {
|
||||
let chunk_x = tile.x * 8 + chunk_lx as i32;
|
||||
let chunk_z = tile.z * 8 + chunk_lz as i32;
|
||||
|
||||
let region_local_x = (chunk_x & 31) as u8;
|
||||
let region_local_z = (chunk_z & 31) as u8;
|
||||
|
||||
let chunk_data = build_chunk_nbt_with_layers(
|
||||
chunk_x,
|
||||
chunk_z,
|
||||
tile,
|
||||
chunk_lx,
|
||||
chunk_lz,
|
||||
layer_exporters,
|
||||
)?;
|
||||
|
||||
if chunk_data.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
region.set_chunk_data(region_local_x, region_local_z, chunk_data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let region_bytes = region.to_bytes()?;
|
||||
let file_name = format!("r.{}.{}.mca", rx, rz);
|
||||
fs::write(output_path.join("region").join(&file_name), ®ion_bytes)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_level_dat(root: &terrafier_nbt::Tag) -> (String, u64, i32) {
|
||||
let default = || ("Unknown".to_string(), 0u64, 3954i32);
|
||||
match root {
|
||||
terrafier_nbt::Tag::Compound(map) => {
|
||||
let data = match map.get("Data") {
|
||||
Some(terrafier_nbt::Tag::Compound(d)) => d,
|
||||
_ => return default(),
|
||||
};
|
||||
let name = match data.get("LevelName") {
|
||||
Some(terrafier_nbt::Tag::String(s)) => s.clone(),
|
||||
_ => "Unknown".to_string(),
|
||||
};
|
||||
let seed = match data.get("WorldGenSettings").and_then(|w| match w {
|
||||
terrafier_nbt::Tag::Compound(m) => m.get("seed"),
|
||||
_ => None,
|
||||
}) {
|
||||
Some(terrafier_nbt::Tag::Long(s)) => *s as u64,
|
||||
_ => 0,
|
||||
};
|
||||
let data_version = match data.get("DataVersion") {
|
||||
Some(terrafier_nbt::Tag::Int(v)) => *v,
|
||||
_ => 3954,
|
||||
};
|
||||
(name, seed, data_version)
|
||||
}
|
||||
_ => default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_level_dat(world: &World) -> Result<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().is_some_and(|e| e == "mca") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
Ok(files)
|
||||
}
|
||||
95
core/src/io/minecraft/export/blocks.rs
Normal file
95
core/src/io/minecraft/export/blocks.rs
Normal 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"
|
||||
}
|
||||
}
|
||||
191
core/src/io/minecraft/export/chunk.rs
Normal file
191
core/src/io/minecraft/export/chunk.rs
Normal 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)
|
||||
}
|
||||
7
core/src/io/minecraft/export/mod.rs
Normal file
7
core/src/io/minecraft/export/mod.rs
Normal 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;
|
||||
148
core/src/io/minecraft/export/writer.rs
Normal file
148
core/src/io/minecraft/export/writer.rs
Normal 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 ®ions {
|
||||
let mut region = terrafier_fastanvil::io::region::Region::new(*rx, *rz);
|
||||
|
||||
for (_key, tile) in tile_refs {
|
||||
for chunk_lx in 0..8usize {
|
||||
for chunk_lz in 0..8usize {
|
||||
let chunk_x = tile.x * 8 + chunk_lx as i32;
|
||||
let chunk_z = tile.z * 8 + chunk_lz as i32;
|
||||
|
||||
let region_local_x = (chunk_x & 31) as u8;
|
||||
let region_local_z = (chunk_z & 31) as u8;
|
||||
|
||||
let chunk_data =
|
||||
build_chunk_nbt(chunk_x, chunk_z, tile, chunk_lx, chunk_lz)?;
|
||||
|
||||
if chunk_data.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
region.set_chunk_data(region_local_x, region_local_z, chunk_data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let region_bytes = region.to_bytes()?;
|
||||
let file_name = format!("r.{}.{}.mca", rx, rz);
|
||||
fs::write(output_path.join("region").join(&file_name), ®ion_bytes)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save a Terrafier World to a Minecraft save directory, applying layer exporters.
|
||||
pub fn save_world_with_layers(
|
||||
world: &World,
|
||||
output_path: &Path,
|
||||
layer_exporters: &[&dyn LayerExport],
|
||||
) -> Result<()> {
|
||||
fs::create_dir_all(output_path.join("region"))?;
|
||||
|
||||
let level_tag = build_level_dat(world)?;
|
||||
let level_bytes = terrafier_nbt::io::writer::to_gzip_bytes(&level_tag)?;
|
||||
fs::write(output_path.join("level.dat"), &level_bytes)?;
|
||||
|
||||
let dim = match world.dimensions.first() {
|
||||
Some(d) => d,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let mut regions: RegionTiles = BTreeMap::new();
|
||||
for (key, tile) in &dim.tiles {
|
||||
let (tx, tz) = key;
|
||||
let rx = tx >> 2;
|
||||
let rz = tz >> 2;
|
||||
regions.entry((rx, rz)).or_default().push((key, tile));
|
||||
}
|
||||
|
||||
for ((rx, rz), tile_refs) in ®ions {
|
||||
let mut region = terrafier_fastanvil::io::region::Region::new(*rx, *rz);
|
||||
|
||||
for (_key, tile) in tile_refs {
|
||||
for chunk_lx in 0..8usize {
|
||||
for chunk_lz in 0..8usize {
|
||||
let chunk_x = tile.x * 8 + chunk_lx as i32;
|
||||
let chunk_z = tile.z * 8 + chunk_lz as i32;
|
||||
|
||||
let region_local_x = (chunk_x & 31) as u8;
|
||||
let region_local_z = (chunk_z & 31) as u8;
|
||||
|
||||
let chunk_data = build_chunk_nbt_with_layers(
|
||||
chunk_x,
|
||||
chunk_z,
|
||||
tile,
|
||||
chunk_lx,
|
||||
chunk_lz,
|
||||
layer_exporters,
|
||||
)?;
|
||||
|
||||
if chunk_data.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
region.set_chunk_data(region_local_x, region_local_z, chunk_data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let region_bytes = region.to_bytes()?;
|
||||
let file_name = format!("r.{}.{}.mca", rx, rz);
|
||||
fs::write(output_path.join("region").join(&file_name), ®ion_bytes)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_level_dat(world: &World) -> Result<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))
|
||||
}
|
||||
36
core/src/io/minecraft/mod.rs
Normal file
36
core/src/io/minecraft/mod.rs
Normal 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>;
|
||||
195
core/src/io/minecraft/reader.rs
Normal file
195
core/src/io/minecraft/reader.rs
Normal 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(®ion_dir)?;
|
||||
for entry in region_entries {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|e| e == "mca") {
|
||||
let file_name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
||||
let parts: Vec<&str> = file_name.split('.').collect();
|
||||
if parts.len() >= 3
|
||||
&& let (Ok(rx), Ok(rz)) = (parts[1].parse::<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)
|
||||
&& 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(®ion_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|e| e == "mca") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
Ok(files)
|
||||
}
|
||||
97
core/src/io/minecraft/version.rs
Normal file
97
core/src/io/minecraft/version.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
162
core/src/io/wp_import/extract.rs
Normal file
162
core/src/io/wp_import/extract.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
180
core/src/io/wp_import/java_reader.rs
Normal file
180
core/src/io/wp_import/java_reader.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
169
core/src/io/wp_import/mod.rs
Normal file
169
core/src/io/wp_import/mod.rs
Normal 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")));
|
||||
}
|
||||
}
|
||||
186
core/src/io/wp_import/types/java_classdesc.rs
Normal file
186
core/src/io/wp_import/types/java_classdesc.rs
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
192
core/src/io/wp_import/types/java_fields.rs
Normal file
192
core/src/io/wp_import/types/java_fields.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
112
core/src/io/wp_import/types/java_types.rs
Normal file
112
core/src/io/wp_import/types/java_types.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
5
core/src/io/wp_import/types/mod.rs
Normal file
5
core/src/io/wp_import/types/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
mod java_classdesc;
|
||||
mod java_fields;
|
||||
mod java_types;
|
||||
|
||||
pub use java_types::*;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
49
core/src/model/layers/caves.rs
Normal file
49
core/src/model/layers/caves.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
74
core/src/model/types.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
5
core/src/model/world/mod.rs
Normal file
5
core/src/model/world/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pub mod dimension;
|
||||
pub mod world;
|
||||
|
||||
pub use dimension::*;
|
||||
pub use world::*;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
183
core/src/ops/layers/mod.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
70
core/src/ops/layers/noise/caves.rs
Normal file
70
core/src/ops/layers/noise/caves.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
37
core/src/ops/layers/noise/frost.rs
Normal file
37
core/src/ops/layers/noise/frost.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
7
core/src/ops/layers/noise/mod.rs
Normal file
7
core/src/ops/layers/noise/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
mod caves;
|
||||
mod frost;
|
||||
mod river;
|
||||
|
||||
pub use caves::CavesGenerator;
|
||||
pub use frost::FrostGenerator;
|
||||
pub use river::RiverGenerator;
|
||||
62
core/src/ops/layers/noise/river.rs
Normal file
62
core/src/ops/layers/noise/river.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
59
core/src/ops/layers/terrain/biome.rs
Normal file
59
core/src/ops/layers/terrain/biome.rs
Normal 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
|
||||
}
|
||||
}
|
||||
7
core/src/ops/layers/terrain/mod.rs
Normal file
7
core/src/ops/layers/terrain/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
pub mod biome;
|
||||
mod resources;
|
||||
mod trees;
|
||||
|
||||
pub use biome::BiomeGenerator;
|
||||
pub use resources::ResourcesGenerator;
|
||||
pub use trees::TreesGenerator;
|
||||
16
core/src/ops/layers/terrain/resources.rs
Normal file
16
core/src/ops/layers/terrain/resources.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
52
core/src/ops/layers/terrain/trees.rs
Normal file
52
core/src/ops/layers/terrain/trees.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
2
core/src/ops/operations/brush/mod.rs
Normal file
2
core/src/ops/operations/brush/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
mod paint;
|
||||
pub use paint::*;
|
||||
202
core/src/ops/operations/brush/paint.rs
Normal file
202
core/src/ops/operations/brush/paint.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
98
core/src/ops/operations/fill.rs
Normal file
98
core/src/ops/operations/fill.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
111
core/src/ops/operations/mod.rs
Normal file
111
core/src/ops/operations/mod.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
152
core/src/ops/operations/shaping/erode.rs
Normal file
152
core/src/ops/operations/shaping/erode.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
196
core/src/ops/operations/shaping/height.rs
Normal file
196
core/src/ops/operations/shaping/height.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
6
core/src/ops/operations/shaping/mod.rs
Normal file
6
core/src/ops/operations/shaping/mod.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
mod height;
|
||||
mod smooth;
|
||||
mod erode;
|
||||
pub use height::*;
|
||||
pub use smooth::*;
|
||||
pub use erode::*;
|
||||
120
core/src/ops/operations/shaping/smooth.rs
Normal file
120
core/src/ops/operations/shaping/smooth.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>>;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>>>;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
31
core/src/plugins/traits.rs
Normal file
31
core/src/plugins/traits.rs
Normal 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);
|
||||
}
|
||||
|
|
@ -151,9 +151,11 @@ fn test_golden_mca_roundtrip() {
|
|||
assert_eq!(dim.name, "overworld");
|
||||
assert_eq!(dim.seed, 42);
|
||||
|
||||
// Note: load_save currently has a TODO on line 129 of minecraft.rs —
|
||||
// parsed chunks are not yet dispatched to tiles, so tiles is always
|
||||
// empty after loading. We verify chunk parsing via the direct test below.
|
||||
// Chunks are dispatched to tiles; verify we have data.
|
||||
assert!(!dim.tiles.is_empty(), "tiles should contain parsed chunks");
|
||||
let tile = dim.tiles.get(&(0, 0)).expect("tile (0,0) should exist");
|
||||
// Chunk at global (0,0) → tile (0,0), height = 4 * 16 + 15 = 79
|
||||
assert_eq!(tile.heightmap[0], 79, "first block height should be 79");
|
||||
|
||||
// --- 2. Direct region + chunk parsing verification ---
|
||||
let region = Region::from_bytes(0, 0, ®ion_bytes).expect("region should parse");
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
//! Chunk data structures for Minecraft Anvil format.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A parsed chunk from an Anvil (.mca) region file.
|
||||
|
|
@ -27,7 +26,6 @@ pub struct ChunkSection {
|
|||
pub block_light: Option<Vec<i8>>,
|
||||
pub sky_light: Option<Vec<i8>>,
|
||||
}
|
||||
|
||||
impl Chunk {
|
||||
/// Parse a chunk from an NBT Compound tag.
|
||||
pub fn from_nbt(tag: &terrafier_nbt::Tag) -> Option<Self> {
|
||||
|
|
@ -86,37 +84,7 @@ impl Chunk {
|
|||
raw,
|
||||
})
|
||||
}
|
||||
|
||||
/// Serialize this chunk back to an NBT Compound tag.
|
||||
pub fn to_nbt(&self) -> terrafier_nbt::Tag {
|
||||
let mut compound = self.raw.clone();
|
||||
|
||||
compound.insert("xPos".into(), terrafier_nbt::Tag::Int(self.x));
|
||||
compound.insert("zPos".into(), terrafier_nbt::Tag::Int(self.z));
|
||||
compound.insert(
|
||||
"DataVersion".into(),
|
||||
terrafier_nbt::Tag::Int(self.data_version),
|
||||
);
|
||||
|
||||
let sections_list: Vec<terrafier_nbt::Tag> =
|
||||
self.sections.iter().map(|s| s.to_nbt()).collect();
|
||||
compound.insert("sections".into(), terrafier_nbt::Tag::List(sections_list));
|
||||
|
||||
if let Some(status) = &self.status {
|
||||
compound.insert("Status".into(), terrafier_nbt::Tag::String(status.clone()));
|
||||
}
|
||||
|
||||
if !self.heightmaps.is_empty() {
|
||||
compound.insert(
|
||||
"Heightmaps".into(),
|
||||
terrafier_nbt::Tag::Compound(self.heightmaps.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
terrafier_nbt::Tag::Compound(compound)
|
||||
}
|
||||
}
|
||||
|
||||
impl ChunkSection {
|
||||
/// Parse a section from an NBT Compound tag.
|
||||
pub fn from_nbt(tag: &terrafier_nbt::Tag) -> Option<Self> {
|
||||
|
|
@ -216,64 +184,7 @@ impl ChunkSection {
|
|||
sky_light,
|
||||
})
|
||||
}
|
||||
|
||||
/// Serialize section back to NBT Compound.
|
||||
pub fn to_nbt(&self) -> terrafier_nbt::Tag {
|
||||
let mut compound = HashMap::new();
|
||||
compound.insert("Y".into(), terrafier_nbt::Tag::Byte(self.section_y));
|
||||
|
||||
// Block states
|
||||
let palette_list: Vec<terrafier_nbt::Tag> = self
|
||||
.palette
|
||||
.iter()
|
||||
.map(|p| terrafier_nbt::Tag::Compound(p.clone()))
|
||||
.collect();
|
||||
let mut block_states = HashMap::new();
|
||||
block_states.insert("palette".into(), terrafier_nbt::Tag::List(palette_list));
|
||||
if !self.block_data.is_empty() {
|
||||
block_states.insert(
|
||||
"data".into(),
|
||||
terrafier_nbt::Tag::LongArray(self.block_data.clone()),
|
||||
);
|
||||
}
|
||||
compound.insert(
|
||||
"block_states".into(),
|
||||
terrafier_nbt::Tag::Compound(block_states),
|
||||
);
|
||||
|
||||
// Biomes
|
||||
let biome_palette_list: Vec<terrafier_nbt::Tag> = self
|
||||
.biome_palette
|
||||
.iter()
|
||||
.map(|p| terrafier_nbt::Tag::Compound(p.clone()))
|
||||
.collect();
|
||||
let mut biomes = HashMap::new();
|
||||
biomes.insert(
|
||||
"palette".into(),
|
||||
terrafier_nbt::Tag::List(biome_palette_list),
|
||||
);
|
||||
if !self.biome_data.is_empty() {
|
||||
biomes.insert(
|
||||
"data".into(),
|
||||
terrafier_nbt::Tag::LongArray(self.biome_data.clone()),
|
||||
);
|
||||
}
|
||||
compound.insert("biomes".into(), terrafier_nbt::Tag::Compound(biomes));
|
||||
|
||||
if let Some(bl) = &self.block_light {
|
||||
compound.insert(
|
||||
"BlockLight".into(),
|
||||
terrafier_nbt::Tag::ByteArray(bl.clone()),
|
||||
);
|
||||
}
|
||||
if let Some(sl) = &self.sky_light {
|
||||
compound.insert("SkyLight".into(), terrafier_nbt::Tag::ByteArray(sl.clone()));
|
||||
}
|
||||
|
||||
terrafier_nbt::Tag::Compound(compound)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_int(map: &HashMap<String, terrafier_nbt::Tag>, key: &str) -> Option<i32> {
|
||||
map.get(key).and_then(|t| match t {
|
||||
terrafier_nbt::Tag::Int(v) => Some(*v),
|
||||
90
crates/fastanvil/src/io/chunk/chunk_write.rs
Normal file
90
crates/fastanvil/src/io/chunk/chunk_write.rs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use super::chunk::{Chunk, ChunkSection};
|
||||
|
||||
impl Chunk {
|
||||
/// Serialize this chunk back to an NBT Compound tag.
|
||||
pub fn to_nbt(&self) -> terrafier_nbt::Tag {
|
||||
let mut compound = self.raw.clone();
|
||||
|
||||
compound.insert("xPos".into(), terrafier_nbt::Tag::Int(self.x));
|
||||
compound.insert("zPos".into(), terrafier_nbt::Tag::Int(self.z));
|
||||
compound.insert(
|
||||
"DataVersion".into(),
|
||||
terrafier_nbt::Tag::Int(self.data_version),
|
||||
);
|
||||
|
||||
let sections_list: Vec<terrafier_nbt::Tag> =
|
||||
self.sections.iter().map(|s| s.to_nbt()).collect();
|
||||
compound.insert("sections".into(), terrafier_nbt::Tag::List(sections_list));
|
||||
|
||||
if let Some(status) = &self.status {
|
||||
compound.insert("Status".into(), terrafier_nbt::Tag::String(status.clone()));
|
||||
}
|
||||
|
||||
if !self.heightmaps.is_empty() {
|
||||
compound.insert(
|
||||
"Heightmaps".into(),
|
||||
terrafier_nbt::Tag::Compound(self.heightmaps.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
terrafier_nbt::Tag::Compound(compound)
|
||||
}
|
||||
}
|
||||
|
||||
impl ChunkSection {
|
||||
/// Serialize section back to NBT Compound.
|
||||
pub fn to_nbt(&self) -> terrafier_nbt::Tag {
|
||||
let mut compound = HashMap::new();
|
||||
compound.insert("Y".into(), terrafier_nbt::Tag::Byte(self.section_y));
|
||||
|
||||
let palette_list: Vec<terrafier_nbt::Tag> = self
|
||||
.palette
|
||||
.iter()
|
||||
.map(|p| terrafier_nbt::Tag::Compound(p.clone()))
|
||||
.collect();
|
||||
let mut block_states = HashMap::new();
|
||||
block_states.insert("palette".into(), terrafier_nbt::Tag::List(palette_list));
|
||||
if !self.block_data.is_empty() {
|
||||
block_states.insert(
|
||||
"data".into(),
|
||||
terrafier_nbt::Tag::LongArray(self.block_data.clone()),
|
||||
);
|
||||
}
|
||||
compound.insert(
|
||||
"block_states".into(),
|
||||
terrafier_nbt::Tag::Compound(block_states),
|
||||
);
|
||||
|
||||
let biome_palette_list: Vec<terrafier_nbt::Tag> = self
|
||||
.biome_palette
|
||||
.iter()
|
||||
.map(|p| terrafier_nbt::Tag::Compound(p.clone()))
|
||||
.collect();
|
||||
let mut biomes = HashMap::new();
|
||||
biomes.insert(
|
||||
"palette".into(),
|
||||
terrafier_nbt::Tag::List(biome_palette_list),
|
||||
);
|
||||
if !self.biome_data.is_empty() {
|
||||
biomes.insert(
|
||||
"data".into(),
|
||||
terrafier_nbt::Tag::LongArray(self.biome_data.clone()),
|
||||
);
|
||||
}
|
||||
compound.insert("biomes".into(), terrafier_nbt::Tag::Compound(biomes));
|
||||
|
||||
if let Some(bl) = &self.block_light {
|
||||
compound.insert(
|
||||
"BlockLight".into(),
|
||||
terrafier_nbt::Tag::ByteArray(bl.clone()),
|
||||
);
|
||||
}
|
||||
if let Some(sl) = &self.sky_light {
|
||||
compound.insert("SkyLight".into(), terrafier_nbt::Tag::ByteArray(sl.clone()));
|
||||
}
|
||||
|
||||
terrafier_nbt::Tag::Compound(compound)
|
||||
}
|
||||
}
|
||||
4
crates/fastanvil/src/io/chunk/mod.rs
Normal file
4
crates/fastanvil/src/io/chunk/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
mod chunk;
|
||||
mod chunk_write;
|
||||
|
||||
pub use chunk::*;
|
||||
|
|
@ -1,4 +1,2 @@
|
|||
//! Anvil format I/O — region file and chunk data structures.
|
||||
|
||||
pub mod chunk;
|
||||
pub mod region;
|
||||
|
|
|
|||
4
crates/fastanvil/src/io/region/mod.rs
Normal file
4
crates/fastanvil/src/io/region/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
mod region;
|
||||
mod region_write;
|
||||
|
||||
pub use region::*;
|
||||
|
|
@ -28,7 +28,7 @@ pub type Result<T> = std::result::Result<T, RegionError>;
|
|||
pub struct Region {
|
||||
pub x: i32,
|
||||
pub z: i32,
|
||||
chunks: HashMap<(u8, u8), ChunkEntry>,
|
||||
pub(crate) chunks: HashMap<(u8, u8), ChunkEntry>,
|
||||
}
|
||||
|
||||
pub struct ChunkEntry {
|
||||
|
|
@ -163,68 +163,5 @@ impl Region {
|
|||
self.chunks.len()
|
||||
}
|
||||
|
||||
/// Serialize region back to .mca bytes.
|
||||
pub fn to_bytes(&self) -> Result<Vec<u8>> {
|
||||
let sector_size: u64 = 4096;
|
||||
let mut locations = [0u32; 1024];
|
||||
let mut timestamps = [0u32; 1024];
|
||||
let mut sector_data: Vec<Vec<u8>> = Vec::new();
|
||||
|
||||
for i in 0..1024 {
|
||||
let local_x = (i % 32) as u8;
|
||||
let local_z = (i / 32) as u8;
|
||||
|
||||
if let Some(entry) = self.chunks.get(&(local_x, local_z)) {
|
||||
timestamps[i] = entry.timestamp;
|
||||
|
||||
// Serialize NBT and compress with Zlib
|
||||
let compressed = compression::compress(
|
||||
entry.data.as_deref().unwrap_or_default(),
|
||||
CompressionType::Zlib,
|
||||
)?;
|
||||
|
||||
// Prepend: length (4 bytes BE) + compression type (1 byte)
|
||||
let total_len = 1 + compressed.len();
|
||||
let mut sector = Vec::with_capacity(4 + total_len);
|
||||
sector.extend(&(total_len as u32).to_be_bytes());
|
||||
sector.push(CompressionType::Zlib.id());
|
||||
sector.extend(&compressed);
|
||||
|
||||
// Pad to sector boundary
|
||||
while sector.len() % sector_size as usize != 0 {
|
||||
sector.push(0);
|
||||
}
|
||||
|
||||
let offset = 2 + sector_data.len() as u32;
|
||||
locations[i] =
|
||||
(offset << 8) | ((sector.len() / sector_size as usize) as u32 & 0xFF);
|
||||
sector_data.push(sector);
|
||||
}
|
||||
}
|
||||
|
||||
// Build output: header + sector data
|
||||
let mut output =
|
||||
Vec::with_capacity(2 * 4096 + sector_data.iter().map(|s| s.len()).sum::<usize>());
|
||||
|
||||
// Location table
|
||||
for loc in locations.iter() {
|
||||
output.extend(&loc.to_be_bytes());
|
||||
}
|
||||
// Timestamp table
|
||||
for ts in timestamps.iter() {
|
||||
output.extend(&ts.to_be_bytes());
|
||||
}
|
||||
|
||||
// Pad header to exactly 2 sectors
|
||||
while output.len() < 2 * sector_size as usize {
|
||||
output.push(0);
|
||||
}
|
||||
|
||||
// Sector data
|
||||
for sector in §or_data {
|
||||
output.extend(sector);
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
62
crates/fastanvil/src/io/region/region_write.rs
Normal file
62
crates/fastanvil/src/io/region/region_write.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
use crate::compression::{self, CompressionType};
|
||||
|
||||
use super::region::{Region, Result};
|
||||
|
||||
impl Region {
|
||||
/// Serialize region back to .mca bytes.
|
||||
pub fn to_bytes(&self) -> Result<Vec<u8>> {
|
||||
let sector_size: u64 = 4096;
|
||||
let mut locations = [0u32; 1024];
|
||||
let mut timestamps = [0u32; 1024];
|
||||
let mut sector_data: Vec<Vec<u8>> = Vec::new();
|
||||
|
||||
for i in 0..1024 {
|
||||
let local_x = (i % 32) as u8;
|
||||
let local_z = (i / 32) as u8;
|
||||
|
||||
if let Some(entry) = self.chunks.get(&(local_x, local_z)) {
|
||||
timestamps[i] = entry.timestamp;
|
||||
|
||||
let compressed = compression::compress(
|
||||
entry.data.as_deref().unwrap_or_default(),
|
||||
CompressionType::Zlib,
|
||||
)?;
|
||||
|
||||
let total_len = 1 + compressed.len();
|
||||
let mut sector = Vec::with_capacity(4 + total_len);
|
||||
sector.extend(&(total_len as u32).to_be_bytes());
|
||||
sector.push(CompressionType::Zlib.id());
|
||||
sector.extend(&compressed);
|
||||
|
||||
while sector.len() % sector_size as usize != 0 {
|
||||
sector.push(0);
|
||||
}
|
||||
|
||||
let offset = 2 + sector_data.len() as u32;
|
||||
locations[i] =
|
||||
(offset << 8) | ((sector.len() / sector_size as usize) as u32 & 0xFF);
|
||||
sector_data.push(sector);
|
||||
}
|
||||
}
|
||||
|
||||
let mut output =
|
||||
Vec::with_capacity(2 * 4096 + sector_data.iter().map(|s| s.len()).sum::<usize>());
|
||||
|
||||
for loc in locations.iter() {
|
||||
output.extend(&loc.to_be_bytes());
|
||||
}
|
||||
for ts in timestamps.iter() {
|
||||
output.extend(&ts.to_be_bytes());
|
||||
}
|
||||
|
||||
while output.len() < 2 * sector_size as usize {
|
||||
output.push(0);
|
||||
}
|
||||
|
||||
for sector in §or_data {
|
||||
output.extend(sector);
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,9 @@
|
|||
//! NBT I/O — binary reader and writer for Java Edition (Big Endian).
|
||||
|
||||
pub mod reader;
|
||||
pub mod writer;
|
||||
pub mod read;
|
||||
pub mod write;
|
||||
|
||||
pub use read::reader;
|
||||
pub use read::read_tag;
|
||||
pub use write::writer;
|
||||
pub use write::write_tag;
|
||||
|
|
|
|||
2
crates/nbt/src/io/read/mod.rs
Normal file
2
crates/nbt/src/io/read/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod reader;
|
||||
pub mod read_tag;
|
||||
101
crates/nbt/src/io/reader.rs → crates/nbt/src/io/read/read_tag.rs
Executable file → Normal file
101
crates/nbt/src/io/reader.rs → crates/nbt/src/io/read/read_tag.rs
Executable file → Normal file
|
|
@ -1,100 +1,11 @@
|
|||
//! NBT binary reader for Java Edition (Big Endian).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Cursor, Read};
|
||||
use thiserror::Error;
|
||||
use std::io::Read;
|
||||
|
||||
use crate::tag::Tag;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ReadError {
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Unknown tag type: {0}")]
|
||||
UnknownTagType(u8),
|
||||
#[error("Invalid string length: {0}")]
|
||||
InvalidStringLength(usize),
|
||||
#[error("Invalid array length: {0}")]
|
||||
InvalidArrayLength(usize),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, ReadError>;
|
||||
|
||||
pub fn read_bytes(data: &[u8]) -> Result<Tag> {
|
||||
let cursor = Cursor::new(data);
|
||||
let mut de = NbtReader::new(cursor);
|
||||
de.read_tag_compound_root()
|
||||
}
|
||||
|
||||
pub fn read_gzip(data: &[u8]) -> Result<Tag> {
|
||||
let mut dec = flate2::read::GzDecoder::new(data);
|
||||
let mut buf = Vec::new();
|
||||
dec.read_to_end(&mut buf)?;
|
||||
read_bytes(&buf)
|
||||
}
|
||||
|
||||
struct NbtReader<R: Read> {
|
||||
inner: R,
|
||||
buf: Vec<u8>,
|
||||
}
|
||||
use super::reader::{NbtReader, ReadError, Result};
|
||||
|
||||
impl<R: Read> NbtReader<R> {
|
||||
fn new(inner: R) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
buf: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_exact(&mut self, len: usize) -> Result<&[u8]> {
|
||||
self.buf.clear();
|
||||
self.buf.resize(len, 0);
|
||||
self.inner.read_exact(&mut self.buf)?;
|
||||
Ok(&self.buf)
|
||||
}
|
||||
|
||||
fn read_u8(&mut self) -> Result<u8> {
|
||||
let mut byte = [0u8; 1];
|
||||
self.inner.read_exact(&mut byte)?;
|
||||
Ok(byte[0])
|
||||
}
|
||||
|
||||
fn read_i16_be(&mut self) -> Result<i16> {
|
||||
let b = self.read_exact(2)?;
|
||||
Ok(i16::from_be_bytes([b[0], b[1]]))
|
||||
}
|
||||
|
||||
fn read_i32_be(&mut self) -> Result<i32> {
|
||||
let b = self.read_exact(4)?;
|
||||
Ok(i32::from_be_bytes([b[0], b[1], b[2], b[3]]))
|
||||
}
|
||||
|
||||
fn read_i64_be(&mut self) -> Result<i64> {
|
||||
let b = self.read_exact(8)?;
|
||||
Ok(i64::from_be_bytes([
|
||||
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
|
||||
]))
|
||||
}
|
||||
|
||||
fn read_f32_be(&mut self) -> Result<f32> {
|
||||
let b = self.read_exact(4)?;
|
||||
Ok(f32::from_be_bytes([b[0], b[1], b[2], b[3]]))
|
||||
}
|
||||
|
||||
fn read_f64_be(&mut self) -> Result<f64> {
|
||||
let b = self.read_exact(8)?;
|
||||
Ok(f64::from_be_bytes([
|
||||
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
|
||||
]))
|
||||
}
|
||||
|
||||
fn read_string(&mut self) -> Result<String> {
|
||||
let len = self.read_i16_be()? as u16 as usize;
|
||||
let bytes = self.read_exact(len)?.to_vec();
|
||||
String::from_utf8(bytes).map_err(|_| ReadError::InvalidStringLength(len))
|
||||
}
|
||||
|
||||
fn read_tag_payload(&mut self, tag_type: u8) -> Result<Tag> {
|
||||
pub(crate) fn read_tag_payload(&mut self, tag_type: u8) -> Result<Tag> {
|
||||
match tag_type {
|
||||
0 => Ok(Tag::End),
|
||||
1 => Ok(Tag::Byte(self.read_u8()? as i8)),
|
||||
|
|
@ -151,7 +62,7 @@ impl<R: Read> NbtReader<R> {
|
|||
}
|
||||
}
|
||||
|
||||
fn read_tag_compound_root(&mut self) -> Result<Tag> {
|
||||
pub(crate) fn read_tag_compound_root(&mut self) -> Result<Tag> {
|
||||
let t = self.read_u8()?;
|
||||
if t == 0 {
|
||||
return Ok(Tag::Compound(HashMap::new()));
|
||||
|
|
@ -166,7 +77,9 @@ impl<R: Read> NbtReader<R> {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::super::reader::read_bytes;
|
||||
use crate::tag::Tag;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn root_envelope(data: &[u8]) -> Vec<u8> {
|
||||
let mut buf = vec![0x09, 0x00, 0x00];
|
||||
95
crates/nbt/src/io/read/reader.rs
Executable file
95
crates/nbt/src/io/read/reader.rs
Executable file
|
|
@ -0,0 +1,95 @@
|
|||
//! NBT binary reader for Java Edition (Big Endian).
|
||||
|
||||
use std::io::{Cursor, Read};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::tag::Tag;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ReadError {
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Unknown tag type: {0}")]
|
||||
UnknownTagType(u8),
|
||||
#[error("Invalid string length: {0}")]
|
||||
InvalidStringLength(usize),
|
||||
#[error("Invalid array length: {0}")]
|
||||
InvalidArrayLength(usize),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, ReadError>;
|
||||
|
||||
pub fn read_bytes(data: &[u8]) -> Result<Tag> {
|
||||
let cursor = Cursor::new(data);
|
||||
let mut de = NbtReader::new(cursor);
|
||||
de.read_tag_compound_root()
|
||||
}
|
||||
|
||||
pub fn read_gzip(data: &[u8]) -> Result<Tag> {
|
||||
let mut dec = flate2::read::GzDecoder::new(data);
|
||||
let mut buf = Vec::new();
|
||||
dec.read_to_end(&mut buf)?;
|
||||
read_bytes(&buf)
|
||||
}
|
||||
|
||||
pub(crate) struct NbtReader<R: Read> {
|
||||
pub(crate) inner: R,
|
||||
pub(crate) buf: Vec<u8>,
|
||||
}
|
||||
|
||||
impl<R: Read> NbtReader<R> {
|
||||
pub(crate) fn new(inner: R) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
buf: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn read_exact(&mut self, len: usize) -> Result<&[u8]> {
|
||||
self.buf.clear();
|
||||
self.buf.resize(len, 0);
|
||||
self.inner.read_exact(&mut self.buf)?;
|
||||
Ok(&self.buf)
|
||||
}
|
||||
|
||||
pub(crate) fn read_u8(&mut self) -> Result<u8> {
|
||||
let mut byte = [0u8; 1];
|
||||
self.inner.read_exact(&mut byte)?;
|
||||
Ok(byte[0])
|
||||
}
|
||||
|
||||
pub(crate) fn read_i16_be(&mut self) -> Result<i16> {
|
||||
let b = self.read_exact(2)?;
|
||||
Ok(i16::from_be_bytes([b[0], b[1]]))
|
||||
}
|
||||
|
||||
pub(crate) fn read_i32_be(&mut self) -> Result<i32> {
|
||||
let b = self.read_exact(4)?;
|
||||
Ok(i32::from_be_bytes([b[0], b[1], b[2], b[3]]))
|
||||
}
|
||||
|
||||
pub(crate) fn read_i64_be(&mut self) -> Result<i64> {
|
||||
let b = self.read_exact(8)?;
|
||||
Ok(i64::from_be_bytes([
|
||||
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
|
||||
]))
|
||||
}
|
||||
|
||||
pub(crate) fn read_f32_be(&mut self) -> Result<f32> {
|
||||
let b = self.read_exact(4)?;
|
||||
Ok(f32::from_be_bytes([b[0], b[1], b[2], b[3]]))
|
||||
}
|
||||
|
||||
pub(crate) fn read_f64_be(&mut self) -> Result<f64> {
|
||||
let b = self.read_exact(8)?;
|
||||
Ok(f64::from_be_bytes([
|
||||
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
|
||||
]))
|
||||
}
|
||||
|
||||
pub(crate) fn read_string(&mut self) -> Result<String> {
|
||||
let len = self.read_i16_be()? as u16 as usize;
|
||||
let bytes = self.read_exact(len)?.to_vec();
|
||||
String::from_utf8(bytes).map_err(|_| ReadError::InvalidStringLength(len))
|
||||
}
|
||||
}
|
||||
2
crates/nbt/src/io/write/mod.rs
Normal file
2
crates/nbt/src/io/write/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod writer;
|
||||
pub mod write_tag;
|
||||
103
crates/nbt/src/io/writer.rs → crates/nbt/src/io/write/write_tag.rs
Executable file → Normal file
103
crates/nbt/src/io/writer.rs → crates/nbt/src/io/write/write_tag.rs
Executable file → Normal file
|
|
@ -1,91 +1,10 @@
|
|||
//! NBT binary writer for Java Edition (Big Endian).
|
||||
|
||||
use std::io::Write;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::tag::Tag;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum WriteError {
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Unsupported tag type in list: {0}")]
|
||||
UnsupportedListType(u8),
|
||||
#[error("Empty list cannot determine element type")]
|
||||
EmptyList,
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, WriteError>;
|
||||
|
||||
/// Serialize a Tag tree to bytes (Big Endian, no compression).
|
||||
pub fn to_bytes(tag: &Tag) -> Result<Vec<u8>> {
|
||||
let mut buf = Vec::new();
|
||||
let mut w = NbtWriter::new(&mut buf);
|
||||
w.write_tag_compound_root(tag)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Serialize a Tag tree to gzip-compressed bytes.
|
||||
pub fn to_gzip_bytes(tag: &Tag) -> Result<Vec<u8>> {
|
||||
let raw = to_bytes(tag)?;
|
||||
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
encoder.write_all(&raw)?;
|
||||
Ok(encoder.finish()?)
|
||||
}
|
||||
|
||||
struct NbtWriter<W: Write> {
|
||||
inner: W,
|
||||
}
|
||||
use super::writer::{NbtWriter, WriteError, Result};
|
||||
|
||||
impl<W: Write> NbtWriter<W> {
|
||||
fn new(inner: W) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
|
||||
fn write_u8(&mut self, val: u8) -> Result<()> {
|
||||
self.inner.write_all(&[val])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_i16_be(&mut self, val: i16) -> Result<()> {
|
||||
self.inner.write_all(&val.to_be_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_i32_be(&mut self, val: i32) -> Result<()> {
|
||||
self.inner.write_all(&val.to_be_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_i64_be(&mut self, val: i64) -> Result<()> {
|
||||
self.inner.write_all(&val.to_be_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_f32_be(&mut self, val: f32) -> Result<()> {
|
||||
self.inner.write_all(&val.to_be_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_f64_be(&mut self, val: f64) -> Result<()> {
|
||||
self.inner.write_all(&val.to_be_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_string(&mut self, s: &str) -> Result<()> {
|
||||
let bytes = s.as_bytes();
|
||||
if bytes.len() > u16::MAX as usize {
|
||||
return Err(WriteError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"String too long for NBT",
|
||||
)));
|
||||
}
|
||||
self.write_i16_be(bytes.len() as i16)?;
|
||||
self.inner.write_all(bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_tag(&mut self, tag: &Tag, name: Option<&str>) -> Result<()> {
|
||||
pub(crate) fn write_tag(&mut self, tag: &Tag, name: Option<&str>) -> Result<()> {
|
||||
self.write_u8(tag.id())?;
|
||||
if let Some(n) = name {
|
||||
self.write_string(n)?;
|
||||
|
|
@ -94,7 +13,7 @@ impl<W: Write> NbtWriter<W> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn write_tag_payload(&mut self, tag: &Tag) -> Result<()> {
|
||||
pub(crate) fn write_tag_payload(&mut self, tag: &Tag) -> Result<()> {
|
||||
match tag {
|
||||
Tag::End => {}
|
||||
Tag::Byte(v) => self.write_u8(*v as u8)?,
|
||||
|
|
@ -106,7 +25,7 @@ impl<W: Write> NbtWriter<W> {
|
|||
Tag::String(v) => self.write_string(v)?,
|
||||
Tag::List(items) => {
|
||||
if items.is_empty() {
|
||||
self.write_u8(1)?; // TAG_Byte as fallback
|
||||
self.write_u8(1)?;
|
||||
self.write_i32_be(0)?;
|
||||
} else {
|
||||
let elem_type = items[0].id();
|
||||
|
|
@ -125,7 +44,7 @@ impl<W: Write> NbtWriter<W> {
|
|||
self.write_tag(val, Some(key))?;
|
||||
}
|
||||
}
|
||||
self.write_u8(0)?; // TAG_End
|
||||
self.write_u8(0)?;
|
||||
}
|
||||
Tag::ByteArray(v) => {
|
||||
self.write_i32_be(v.len() as i32)?;
|
||||
|
|
@ -149,11 +68,11 @@ impl<W: Write> NbtWriter<W> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn write_tag_compound_root(&mut self, tag: &Tag) -> Result<()> {
|
||||
pub(crate) fn write_tag_compound_root(&mut self, tag: &Tag) -> Result<()> {
|
||||
match tag {
|
||||
Tag::Compound(map) => {
|
||||
self.write_u8(9)?; // TAG_Compound
|
||||
self.write_string("")?; // empty root name
|
||||
self.write_u8(9)?;
|
||||
self.write_string("")?;
|
||||
let mut keys: Vec<&String> = map.keys().collect();
|
||||
keys.sort();
|
||||
for key in keys {
|
||||
|
|
@ -161,7 +80,7 @@ impl<W: Write> NbtWriter<W> {
|
|||
self.write_tag(val, Some(key))?;
|
||||
}
|
||||
}
|
||||
self.write_u8(0)?; // TAG_End
|
||||
self.write_u8(0)?;
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(WriteError::Io(std::io::Error::new(
|
||||
|
|
@ -174,8 +93,10 @@ impl<W: Write> NbtWriter<W> {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::super::writer::to_bytes;
|
||||
use super::super::writer::to_gzip_bytes;
|
||||
use crate::io::reader;
|
||||
use crate::tag::Tag;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
87
crates/nbt/src/io/write/writer.rs
Executable file
87
crates/nbt/src/io/write/writer.rs
Executable file
|
|
@ -0,0 +1,87 @@
|
|||
//! NBT binary writer for Java Edition (Big Endian).
|
||||
|
||||
use std::io::Write;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::tag::Tag;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum WriteError {
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Unsupported tag type in list: {0}")]
|
||||
UnsupportedListType(u8),
|
||||
#[error("Empty list cannot determine element type")]
|
||||
EmptyList,
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, WriteError>;
|
||||
|
||||
/// Serialize a Tag tree to bytes (Big Endian, no compression).
|
||||
pub fn to_bytes(tag: &Tag) -> Result<Vec<u8>> {
|
||||
let mut buf = Vec::new();
|
||||
let mut w = NbtWriter::new(&mut buf);
|
||||
w.write_tag_compound_root(tag)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Serialize a Tag tree to gzip-compressed bytes.
|
||||
pub fn to_gzip_bytes(tag: &Tag) -> Result<Vec<u8>> {
|
||||
let raw = to_bytes(tag)?;
|
||||
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
encoder.write_all(&raw)?;
|
||||
Ok(encoder.finish()?)
|
||||
}
|
||||
|
||||
pub(crate) struct NbtWriter<W: Write> {
|
||||
pub(crate) inner: W,
|
||||
}
|
||||
|
||||
impl<W: Write> NbtWriter<W> {
|
||||
pub(crate) fn new(inner: W) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
|
||||
pub(crate) fn write_u8(&mut self, val: u8) -> Result<()> {
|
||||
self.inner.write_all(&[val])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn write_i16_be(&mut self, val: i16) -> Result<()> {
|
||||
self.inner.write_all(&val.to_be_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn write_i32_be(&mut self, val: i32) -> Result<()> {
|
||||
self.inner.write_all(&val.to_be_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn write_i64_be(&mut self, val: i64) -> Result<()> {
|
||||
self.inner.write_all(&val.to_be_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn write_f32_be(&mut self, val: f32) -> Result<()> {
|
||||
self.inner.write_all(&val.to_be_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn write_f64_be(&mut self, val: f64) -> Result<()> {
|
||||
self.inner.write_all(&val.to_be_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn write_string(&mut self, s: &str) -> Result<()> {
|
||||
let bytes = s.as_bytes();
|
||||
if bytes.len() > u16::MAX as usize {
|
||||
return Err(WriteError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"String too long for NBT",
|
||||
)));
|
||||
}
|
||||
self.write_i16_be(bytes.len() as i16)?;
|
||||
self.inner.write_all(bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
4
crates/palette-compress/src/palette/mod.rs
Normal file
4
crates/palette-compress/src/palette/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
mod palette;
|
||||
mod palette_io;
|
||||
|
||||
pub use palette::*;
|
||||
82
crates/palette-compress/src/palette/palette.rs
Executable file
82
crates/palette-compress/src/palette/palette.rs
Executable file
|
|
@ -0,0 +1,82 @@
|
|||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BlockState {
|
||||
pub name: String,
|
||||
pub properties: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Hash for BlockState {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.name.hash(state);
|
||||
let mut keys: Vec<&String> = self.properties.keys().collect();
|
||||
keys.sort();
|
||||
for k in keys {
|
||||
k.hash(state);
|
||||
self.properties[k].hash(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockState {
|
||||
pub fn new(name: &str) -> Self {
|
||||
BlockState {
|
||||
name: name.to_string(),
|
||||
properties: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_property(mut self, key: &str, val: &str) -> Self {
|
||||
self.properties.insert(key.to_string(), val.to_string());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A palette mapping unique `BlockState`s to compact indices.
|
||||
pub struct BlockPalette {
|
||||
pub entries: Vec<BlockState>,
|
||||
index_map: HashMap<BlockState, u32>,
|
||||
}
|
||||
|
||||
impl Default for BlockPalette {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockPalette {
|
||||
pub fn new() -> Self {
|
||||
BlockPalette {
|
||||
entries: Vec::new(),
|
||||
index_map: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a block state, or return its existing index.
|
||||
pub fn add_or_get(&mut self, state: BlockState) -> u32 {
|
||||
if let Some(&idx) = self.index_map.get(&state) {
|
||||
return idx;
|
||||
}
|
||||
let idx = self.entries.len() as u32;
|
||||
self.index_map.insert(state.clone(), idx);
|
||||
self.entries.push(state);
|
||||
idx
|
||||
}
|
||||
|
||||
pub fn get(&self, index: u32) -> Option<&BlockState> {
|
||||
self.entries.get(index as usize)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Minimum bits per entry needed for this palette.
|
||||
pub fn bits_per_entry(&self) -> u8 {
|
||||
crate::bits::BitArray::bits_needed(self.entries.len())
|
||||
}
|
||||
}
|
||||
81
crates/palette-compress/src/palette.rs → crates/palette-compress/src/palette/palette_io.rs
Executable file → Normal file
81
crates/palette-compress/src/palette.rs → crates/palette-compress/src/palette/palette_io.rs
Executable file → Normal file
|
|
@ -1,39 +1,9 @@
|
|||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use terrafier_nbt::Tag;
|
||||
|
||||
/// A Minecraft block state — name plus key-value properties.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BlockState {
|
||||
pub name: String,
|
||||
pub properties: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Hash for BlockState {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.name.hash(state);
|
||||
let mut keys: Vec<&String> = self.properties.keys().collect();
|
||||
keys.sort();
|
||||
for k in keys {
|
||||
k.hash(state);
|
||||
self.properties[k].hash(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
use super::palette::{BlockPalette, BlockState};
|
||||
|
||||
impl BlockState {
|
||||
pub fn new(name: &str) -> Self {
|
||||
BlockState {
|
||||
name: name.to_string(),
|
||||
properties: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_property(mut self, key: &str, val: &str) -> Self {
|
||||
self.properties.insert(key.to_string(), val.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// Parse from `TAG_Compound` with `"Name"` and optional `"Properties"`.
|
||||
pub fn from_nbt(tag: &Tag) -> Option<Self> {
|
||||
let map = match tag {
|
||||
|
|
@ -78,53 +48,7 @@ impl BlockState {
|
|||
}
|
||||
}
|
||||
|
||||
/// A palette mapping unique `BlockState`s to compact indices.
|
||||
pub struct BlockPalette {
|
||||
pub entries: Vec<BlockState>,
|
||||
index_map: HashMap<BlockState, u32>,
|
||||
}
|
||||
|
||||
impl Default for BlockPalette {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockPalette {
|
||||
pub fn new() -> Self {
|
||||
BlockPalette {
|
||||
entries: Vec::new(),
|
||||
index_map: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a block state, or return its existing index.
|
||||
pub fn add_or_get(&mut self, state: BlockState) -> u32 {
|
||||
if let Some(&idx) = self.index_map.get(&state) {
|
||||
return idx;
|
||||
}
|
||||
let idx = self.entries.len() as u32;
|
||||
self.index_map.insert(state.clone(), idx);
|
||||
self.entries.push(state);
|
||||
idx
|
||||
}
|
||||
|
||||
pub fn get(&self, index: u32) -> Option<&BlockState> {
|
||||
self.entries.get(index as usize)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Minimum bits per entry needed for this palette.
|
||||
pub fn bits_per_entry(&self) -> u8 {
|
||||
crate::bits::BitArray::bits_needed(self.entries.len())
|
||||
}
|
||||
|
||||
/// Import from NBT palette list + block data `LongArray`.
|
||||
pub fn from_nbt(palette_list: &[Tag], data: &[i64]) -> (Self, crate::bits::BitArray) {
|
||||
let mut palette = BlockPalette::new();
|
||||
|
|
@ -149,7 +73,8 @@ impl BlockPalette {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::super::palette::{BlockPalette, BlockState};
|
||||
use terrafier_nbt::Tag;
|
||||
|
||||
#[test]
|
||||
fn test_add_get() {
|
||||
|
|
@ -12,5 +12,7 @@ log.workspace = true
|
|||
env_logger.workspace = true
|
||||
|
||||
egui = "0.31"
|
||||
eframe = "0.31"
|
||||
eframe = { version = "0.31", features = ["wgpu"] }
|
||||
egui-wgpu = "0.31"
|
||||
wgpu = { version = "24", features = ["vulkan-portability", "webgl"] }
|
||||
rfd = "0.15"
|
||||
|
|
|
|||
185
gui/src/app.rs
185
gui/src/app.rs
|
|
@ -1,185 +0,0 @@
|
|||
use std::collections::VecDeque;
|
||||
|
||||
use terrafier_core::World;
|
||||
use terrafier_core::model::terrain::Terrain;
|
||||
use terrafier_core::ops::operations::Operation;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum ToolMode {
|
||||
Raise,
|
||||
Lower,
|
||||
Smooth,
|
||||
Flatten,
|
||||
Paint,
|
||||
Inspect,
|
||||
}
|
||||
|
||||
impl ToolMode {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
ToolMode::Raise => "Raise",
|
||||
ToolMode::Lower => "Lower",
|
||||
ToolMode::Smooth => "Smooth",
|
||||
ToolMode::Flatten => "Flatten",
|
||||
ToolMode::Paint => "Paint",
|
||||
ToolMode::Inspect => "Inspect",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TerrafierApp {
|
||||
pub world: Option<World>,
|
||||
pub selected_tile: Option<(i32, i32)>,
|
||||
pub tool_mode: ToolMode,
|
||||
pub brush_radius: u32,
|
||||
pub brush_strength: f64,
|
||||
pub selected_terrain: Terrain,
|
||||
pub target_height: i16,
|
||||
pub smooth_iterations: u32,
|
||||
pub undo_stack: VecDeque<Box<dyn Operation>>,
|
||||
pub redo_stack: Vec<Box<dyn Operation>>,
|
||||
pub brush_local_x: Option<u32>,
|
||||
pub brush_local_z: Option<u32>,
|
||||
pub show_heightmap: bool,
|
||||
pub show_new_world: bool,
|
||||
pub show_export: bool,
|
||||
pub status_message: String,
|
||||
pub world_name: String,
|
||||
pub world_seed: String,
|
||||
pub export_path: String,
|
||||
}
|
||||
|
||||
impl TerrafierApp {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
world: None,
|
||||
selected_tile: None,
|
||||
tool_mode: ToolMode::Raise,
|
||||
brush_radius: 16,
|
||||
brush_strength: 0.5,
|
||||
selected_terrain: Terrain::Grass,
|
||||
target_height: 64,
|
||||
smooth_iterations: 3,
|
||||
undo_stack: VecDeque::new(),
|
||||
redo_stack: Vec::new(),
|
||||
brush_local_x: None,
|
||||
brush_local_z: None,
|
||||
show_heightmap: false,
|
||||
show_new_world: false,
|
||||
show_export: false,
|
||||
status_message: "Ready".to_string(),
|
||||
world_name: String::new(),
|
||||
world_seed: String::new(),
|
||||
export_path: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_for_undo(&mut self, op: Box<dyn Operation>) {
|
||||
self.undo_stack.push_back(op);
|
||||
if self.undo_stack.len() > 50 {
|
||||
self.undo_stack.pop_front();
|
||||
}
|
||||
self.redo_stack.clear();
|
||||
}
|
||||
|
||||
pub fn undo(&mut self) {
|
||||
if let Some(op) = self.undo_stack.pop_back() {
|
||||
if let Some(ref mut world) = self.world {
|
||||
let dim = world.overworld_mut().expect("world has no overworld");
|
||||
let inv = op.inverse();
|
||||
if let Err(e) = inv.apply(dim) {
|
||||
self.status_message = format!("Undo error: {:?}", e);
|
||||
self.undo_stack.push_back(op);
|
||||
return;
|
||||
}
|
||||
self.redo_stack.push(op);
|
||||
self.status_message = "Undo".to_string();
|
||||
}
|
||||
} else {
|
||||
self.status_message = "Nothing to undo".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn redo(&mut self) {
|
||||
if let Some(op) = self.redo_stack.pop() {
|
||||
if let Some(ref mut world) = self.world {
|
||||
let dim = world.overworld_mut().expect("world has no overworld");
|
||||
if let Err(e) = op.apply(dim) {
|
||||
self.status_message = format!("Redo error: {:?}", e);
|
||||
self.redo_stack.push(op);
|
||||
return;
|
||||
}
|
||||
self.undo_stack.push_back(op);
|
||||
self.status_message = "Redo".to_string();
|
||||
}
|
||||
} else {
|
||||
self.status_message = "Nothing to redo".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for TerrafierApp {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
egui::TopBottomPanel::top("menu").show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("New World").clicked() {
|
||||
self.show_new_world = true;
|
||||
}
|
||||
if ui.button("Open").clicked()
|
||||
&& let Some(path) = rfd::FileDialog::new().pick_folder()
|
||||
{
|
||||
match terrafier_core::io::import::import(&path) {
|
||||
Ok(world) => {
|
||||
self.world = Some(world);
|
||||
self.selected_tile = None;
|
||||
self.undo_stack.clear();
|
||||
self.redo_stack.clear();
|
||||
self.status_message = format!("Opened world from {}", path.display());
|
||||
}
|
||||
Err(e) => {
|
||||
self.status_message = format!("Open error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ui.button("Export").clicked() {
|
||||
self.show_export = true;
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Undo").clicked() {
|
||||
self.undo();
|
||||
}
|
||||
if ui.button("Redo").clicked() {
|
||||
self.redo();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
egui::SidePanel::left("tools")
|
||||
.resizable(false)
|
||||
.default_width(200.0)
|
||||
.show(ctx, |ui| {
|
||||
crate::tools::show_tools_panel(ui, self);
|
||||
});
|
||||
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
crate::view::show_viewport(ui, self);
|
||||
});
|
||||
|
||||
egui::TopBottomPanel::bottom("status").show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(&self.status_message);
|
||||
if let Some((tx, tz)) = self.selected_tile {
|
||||
ui.separator();
|
||||
ui.label(format!("Tile ({}, {})", tx, tz));
|
||||
}
|
||||
if self.world.is_some() {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label("World loaded");
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
crate::dialogs::handle_dialogs(self, ctx);
|
||||
}
|
||||
}
|
||||
132
gui/src/app/mod.rs
Normal file
132
gui/src/app/mod.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
use std::collections::VecDeque;
|
||||
|
||||
use terrafier_core::World;
|
||||
use terrafier_core::model::types::Terrain;
|
||||
use terrafier_core::ops::operations::Operation;
|
||||
|
||||
use crate::renderer::GpuRenderer;
|
||||
|
||||
mod ops;
|
||||
mod update;
|
||||
|
||||
pub enum GpuStatus {
|
||||
Enabled,
|
||||
FallbackCpu(&'static str),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum ToolMode {
|
||||
Raise,
|
||||
Lower,
|
||||
Smooth,
|
||||
Flatten,
|
||||
Paint,
|
||||
Erode,
|
||||
Fill,
|
||||
Inspect,
|
||||
}
|
||||
|
||||
impl ToolMode {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
ToolMode::Raise => "Raise",
|
||||
ToolMode::Lower => "Lower",
|
||||
ToolMode::Smooth => "Smooth",
|
||||
ToolMode::Flatten => "Flatten",
|
||||
ToolMode::Paint => "Paint",
|
||||
ToolMode::Erode => "Erode",
|
||||
ToolMode::Fill => "Fill",
|
||||
ToolMode::Inspect => "Inspect",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const LAYER_CAVES_IDX: usize = 0;
|
||||
pub const LAYER_RIVER_IDX: usize = 1;
|
||||
pub const LAYER_FROST_IDX: usize = 2;
|
||||
pub const LAYER_TREES_IDX: usize = 3;
|
||||
pub const LAYER_BIOME_IDX: usize = 4;
|
||||
pub const LAYER_RESOURCES_IDX: usize = 5;
|
||||
pub const LAYER_NAMES: [&str; 6] = ["Caves", "River", "Frost", "Trees", "Biome", "Resources"];
|
||||
|
||||
pub struct TerrafierApp {
|
||||
pub world: Option<World>,
|
||||
pub selected_tile: Option<(i32, i32)>,
|
||||
pub tool_mode: ToolMode,
|
||||
pub brush_radius: u32,
|
||||
pub brush_strength: f64,
|
||||
pub selected_terrain: Terrain,
|
||||
pub target_height: i16,
|
||||
pub smooth_iterations: u32,
|
||||
pub erode_iterations: u32,
|
||||
pub erode_talus_angle: f64,
|
||||
pub undo_stack: VecDeque<Box<dyn Operation>>,
|
||||
pub redo_stack: Vec<Box<dyn Operation>>,
|
||||
pub brush_local_x: Option<u32>,
|
||||
pub brush_local_z: Option<u32>,
|
||||
pub show_heightmap: bool,
|
||||
pub show_new_world: bool,
|
||||
pub show_export: bool,
|
||||
pub status_message: String,
|
||||
pub world_name: String,
|
||||
pub world_seed: String,
|
||||
pub export_path: String,
|
||||
pub renderer: Option<GpuRenderer>,
|
||||
pub show_gpu_render: bool,
|
||||
pub gpu_status: GpuStatus,
|
||||
pub cpu_texture_handle: Option<egui::TextureHandle>,
|
||||
pub layer_visible: [bool; 6],
|
||||
}
|
||||
|
||||
impl TerrafierApp {
|
||||
pub fn new(cc: &eframe::CreationContext) -> Self {
|
||||
let (renderer, gpu_status) = if let Some(render_state) = &cc.wgpu_render_state {
|
||||
let device: std::sync::Arc<wgpu::Device> = render_state.device.clone().into();
|
||||
let queue: std::sync::Arc<wgpu::Queue> = render_state.queue.clone().into();
|
||||
let adapter: std::sync::Arc<wgpu::Adapter> = render_state.adapter.clone().into();
|
||||
let mut gpu_renderer = GpuRenderer::new(
|
||||
device,
|
||||
queue,
|
||||
&adapter,
|
||||
128,
|
||||
128,
|
||||
include_str!("../renderer/shaders.wgsl"),
|
||||
);
|
||||
let mut renderer_lock = render_state.renderer.write();
|
||||
gpu_renderer.register_texture(&mut renderer_lock);
|
||||
(Some(gpu_renderer), GpuStatus::Enabled)
|
||||
} else {
|
||||
(None, GpuStatus::FallbackCpu("GPU init failed — no wgpu adapter found"))
|
||||
};
|
||||
|
||||
Self {
|
||||
world: None,
|
||||
selected_tile: None,
|
||||
tool_mode: ToolMode::Raise,
|
||||
brush_radius: 16,
|
||||
brush_strength: 0.5,
|
||||
selected_terrain: Terrain::Grass,
|
||||
target_height: 64,
|
||||
smooth_iterations: 3,
|
||||
erode_iterations: 3,
|
||||
erode_talus_angle: 0.5,
|
||||
undo_stack: VecDeque::new(),
|
||||
redo_stack: Vec::new(),
|
||||
brush_local_x: None,
|
||||
brush_local_z: None,
|
||||
show_heightmap: false,
|
||||
show_new_world: false,
|
||||
show_export: false,
|
||||
status_message: "Ready".to_string(),
|
||||
world_name: String::new(),
|
||||
world_seed: String::new(),
|
||||
export_path: String::new(),
|
||||
renderer,
|
||||
show_gpu_render: true,
|
||||
gpu_status,
|
||||
cpu_texture_handle: None,
|
||||
layer_visible: [false; 6],
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
48
gui/src/app/ops.rs
Normal file
48
gui/src/app/ops.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
use terrafier_core::ops::operations::Operation;
|
||||
|
||||
use crate::app::TerrafierApp;
|
||||
|
||||
impl TerrafierApp {
|
||||
pub fn save_for_undo(&mut self, op: Box<dyn Operation>) {
|
||||
self.undo_stack.push_back(op);
|
||||
if self.undo_stack.len() > 50 {
|
||||
self.undo_stack.pop_front();
|
||||
}
|
||||
self.redo_stack.clear();
|
||||
}
|
||||
|
||||
pub fn undo(&mut self) {
|
||||
if let Some(op) = self.undo_stack.pop_back() {
|
||||
if let Some(ref mut world) = self.world {
|
||||
let dim = world.overworld_mut().expect("world has no overworld");
|
||||
let inv = op.inverse();
|
||||
if let Err(e) = inv.apply(dim) {
|
||||
self.status_message = format!("Undo error: {:?}", e);
|
||||
self.undo_stack.push_back(op);
|
||||
return;
|
||||
}
|
||||
self.redo_stack.push(op);
|
||||
self.status_message = "Undo".to_string();
|
||||
}
|
||||
} else {
|
||||
self.status_message = "Nothing to undo".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn redo(&mut self) {
|
||||
if let Some(op) = self.redo_stack.pop() {
|
||||
if let Some(ref mut world) = self.world {
|
||||
let dim = world.overworld_mut().expect("world has no overworld");
|
||||
if let Err(e) = op.apply(dim) {
|
||||
self.status_message = format!("Redo error: {:?}", e);
|
||||
self.redo_stack.push(op);
|
||||
return;
|
||||
}
|
||||
self.undo_stack.push_back(op);
|
||||
self.status_message = "Redo".to_string();
|
||||
}
|
||||
} else {
|
||||
self.status_message = "Nothing to redo".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
91
gui/src/app/update.rs
Normal file
91
gui/src/app/update.rs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
use crate::app::{GpuStatus, TerrafierApp};
|
||||
|
||||
impl eframe::App for TerrafierApp {
|
||||
fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
||||
egui::TopBottomPanel::top("menu").show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("New World").clicked() {
|
||||
self.show_new_world = true;
|
||||
}
|
||||
if ui.button("Open").clicked()
|
||||
&& let Some(path) = rfd::FileDialog::new().pick_folder()
|
||||
{
|
||||
match terrafier_core::io::import::import(&path) {
|
||||
Ok(world) => {
|
||||
self.world = Some(world);
|
||||
self.selected_tile = None;
|
||||
self.undo_stack.clear();
|
||||
self.redo_stack.clear();
|
||||
self.status_message = format!("Opened world from {}", path.display());
|
||||
}
|
||||
Err(e) => {
|
||||
self.status_message = format!("Open error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ui.button("Export").clicked() {
|
||||
self.show_export = true;
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Undo").clicked() {
|
||||
self.undo();
|
||||
}
|
||||
if ui.button("Redo").clicked() {
|
||||
self.redo();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
egui::SidePanel::left("tools")
|
||||
.resizable(false)
|
||||
.default_width(200.0)
|
||||
.show(ctx, |ui| {
|
||||
crate::view::show_tools_panel(ui, self);
|
||||
});
|
||||
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
crate::view::show_viewport(ui, self);
|
||||
});
|
||||
|
||||
egui::TopBottomPanel::bottom("status").show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(&self.status_message);
|
||||
if let Some((tx, tz)) = self.selected_tile {
|
||||
ui.separator();
|
||||
ui.label(format!("Tile ({}, {})", tx, tz));
|
||||
}
|
||||
if self.world.is_some() {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label("World loaded");
|
||||
});
|
||||
}
|
||||
if matches!(self.gpu_status, GpuStatus::FallbackCpu(_)) {
|
||||
ui.separator();
|
||||
ui.label(" CPU fallback");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if let Some(ref mut renderer) = self.renderer
|
||||
&& let Some(ref world) = self.world
|
||||
&& let Some(tile) = world.dimensions[0].tiles.values().next()
|
||||
{
|
||||
renderer.render(
|
||||
&tile.heightmap.as_slice().try_into().expect("heightmap size"),
|
||||
&tile.terrain.as_slice().try_into().expect("terrain size"),
|
||||
self.show_heightmap,
|
||||
);
|
||||
|
||||
if let Some(render_state) = frame.wgpu_render_state() {
|
||||
let mut renderer_lock = render_state.renderer.write();
|
||||
if renderer.texture_id.is_none() {
|
||||
renderer.register_texture(&mut renderer_lock);
|
||||
} else {
|
||||
renderer.update_texture(&mut renderer_lock);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
crate::dialogs::handle_dialogs(self, ctx);
|
||||
}
|
||||
}
|
||||
6
gui/src/lib.rs
Normal file
6
gui/src/lib.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
mod app;
|
||||
mod dialogs;
|
||||
mod renderer;
|
||||
mod view;
|
||||
|
||||
pub use app::TerrafierApp;
|
||||
|
|
@ -1,15 +1,15 @@
|
|||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
fn main() -> Result<(), eframe::Error> {
|
||||
env_logger::init();
|
||||
let native_options = eframe::NativeOptions::default();
|
||||
|
||||
let native_options = eframe::NativeOptions {
|
||||
renderer: eframe::Renderer::Wgpu,
|
||||
viewport: egui::ViewportBuilder::default().with_inner_size([1280.0, 800.0]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
eframe::run_native(
|
||||
"Terrafier",
|
||||
native_options,
|
||||
Box::new(|_cc| Ok(Box::new(app::TerrafierApp::new()))),
|
||||
)?;
|
||||
Ok(())
|
||||
Box::new(|cc| Ok(Box::new(terrafier_gui::TerrafierApp::new(cc)))),
|
||||
)
|
||||
}
|
||||
|
||||
mod app;
|
||||
mod dialogs;
|
||||
mod tools;
|
||||
mod view;
|
||||
|
|
|
|||
3
gui/src/renderer/mod.rs
Normal file
3
gui/src/renderer/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
mod pipeline;
|
||||
mod render;
|
||||
pub use pipeline::*;
|
||||
140
gui/src/renderer/pipeline.rs
Normal file
140
gui/src/renderer/pipeline.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Uniforms {
|
||||
pub(crate) min_height: i32,
|
||||
pub(crate) max_height: i32,
|
||||
pub(crate) show_heightmap: u32,
|
||||
pub(crate) tile_size: u32,
|
||||
}
|
||||
|
||||
pub struct GpuRenderer {
|
||||
pub(crate) device: Arc<wgpu::Device>,
|
||||
pub(crate) queue: Arc<wgpu::Queue>,
|
||||
pub(crate) compute_pipeline: wgpu::ComputePipeline,
|
||||
pub(crate) heightmap_buffer: wgpu::Buffer,
|
||||
pub(crate) terrain_buffer: wgpu::Buffer,
|
||||
pub(crate) uniform_buffer: wgpu::Buffer,
|
||||
pub(crate) output_buffer: wgpu::Buffer,
|
||||
pub(crate) output_texture: wgpu::Texture,
|
||||
pub(crate) output_texture_view: wgpu::TextureView,
|
||||
pub(crate) bind_group: wgpu::BindGroup,
|
||||
pub texture_id: Option<egui::TextureId>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub(crate) last_heightmap: Vec<i16>,
|
||||
pub(crate) last_terrain: Vec<u8>,
|
||||
pub(crate) last_show_heightmap: bool,
|
||||
pub(crate) needs_upload: bool,
|
||||
}
|
||||
|
||||
impl GpuRenderer {
|
||||
pub fn new(
|
||||
device: Arc<wgpu::Device>,
|
||||
queue: Arc<wgpu::Queue>,
|
||||
adapter: &wgpu::Adapter,
|
||||
width: u32,
|
||||
height: u32,
|
||||
shader_source: &str,
|
||||
) -> Self {
|
||||
let limits = adapter.limits();
|
||||
let needed_storage: u64 = 65536;
|
||||
if limits.max_buffer_size < needed_storage {
|
||||
log::warn!(
|
||||
"Adapter buffer size limit ({}) < needed ({})",
|
||||
limits.max_buffer_size,
|
||||
needed_storage,
|
||||
);
|
||||
}
|
||||
let bind_group_layout =
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("terrain_bind_group_layout"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }, count: None },
|
||||
wgpu::BindGroupLayoutEntry { binding: 1, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: true }, has_dynamic_offset: false, min_binding_size: None }, count: None },
|
||||
wgpu::BindGroupLayoutEntry { binding: 2, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: true }, has_dynamic_offset: false, min_binding_size: None }, count: None },
|
||||
wgpu::BindGroupLayoutEntry { binding: 3, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: false }, has_dynamic_offset: false, min_binding_size: None }, count: None },
|
||||
],
|
||||
});
|
||||
let shader_module =
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("terrain_shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(shader_source.into()),
|
||||
});
|
||||
let pipeline_layout =
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("terrain_pipeline_layout"),
|
||||
bind_group_layouts: &[&bind_group_layout],
|
||||
push_constant_ranges: &[],
|
||||
});
|
||||
let compute_pipeline =
|
||||
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("terrain_compute_pipeline"),
|
||||
layout: Some(&pipeline_layout),
|
||||
module: &shader_module,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||||
cache: None,
|
||||
});
|
||||
let heightmap_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("heightmap_buffer"), size: 8192 * 4,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false,
|
||||
});
|
||||
let terrain_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("terrain_buffer"), size: 4096 * 4,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false,
|
||||
});
|
||||
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("uniform_buffer"), size: std::mem::size_of::<Uniforms>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false,
|
||||
});
|
||||
let output_size = (width * height * 4) as u64;
|
||||
let output_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("output_buffer"), size: output_size,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false,
|
||||
});
|
||||
let output_texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("output_texture"),
|
||||
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
|
||||
mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8UnormSrgb,
|
||||
view_formats: &[wgpu::TextureFormat::Rgba8UnormSrgb],
|
||||
usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
});
|
||||
let output_texture_view = output_texture.create_view(&Default::default());
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("terrain_bind_group"),
|
||||
layout: &bind_group_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: &uniform_buffer, offset: 0, size: None }) },
|
||||
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: &heightmap_buffer, offset: 0, size: None }) },
|
||||
wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: &terrain_buffer, offset: 0, size: None }) },
|
||||
wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: &output_buffer, offset: 0, size: None }) },
|
||||
],
|
||||
});
|
||||
Self {
|
||||
device, queue, compute_pipeline, heightmap_buffer, terrain_buffer,
|
||||
uniform_buffer, output_buffer, output_texture, output_texture_view, bind_group,
|
||||
texture_id: None, width, height,
|
||||
last_heightmap: vec![0i16; 16384],
|
||||
last_terrain: vec![0u8; 16384],
|
||||
last_show_heightmap: false,
|
||||
needs_upload: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pack_heightmap(heightmap: &[i16; 16384]) -> Vec<u32> {
|
||||
heightmap.chunks(2).map(|chunk| {
|
||||
let low = chunk[0] as u32 & 0xFFFF;
|
||||
let high = chunk[1] as u32 & 0xFFFF;
|
||||
low | (high << 16)
|
||||
}).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn pack_terrain(terrain: &[u8; 16384]) -> Vec<u32> {
|
||||
terrain.chunks(4).map(|chunk| {
|
||||
chunk[0] as u32 | (chunk[1] as u32) << 8 | (chunk[2] as u32) << 16 | (chunk[3] as u32) << 24
|
||||
}).collect()
|
||||
}
|
||||
}
|
||||
123
gui/src/renderer/render.rs
Normal file
123
gui/src/renderer/render.rs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
use crate::renderer::GpuRenderer;
|
||||
use crate::renderer::Uniforms;
|
||||
|
||||
fn as_u8_slice<T>(data: &[T]) -> &[u8] {
|
||||
unsafe {
|
||||
std::slice::from_raw_parts(
|
||||
data.as_ptr() as *const u8,
|
||||
std::mem::size_of_val(data),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn as_u8_value<T>(data: &T) -> &[u8] {
|
||||
unsafe {
|
||||
std::slice::from_raw_parts(
|
||||
data as *const T as *const u8,
|
||||
std::mem::size_of::<T>(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl GpuRenderer {
|
||||
pub fn render(
|
||||
&mut self,
|
||||
heightmap: &[i16; 16384],
|
||||
terrain: &[u8; 16384],
|
||||
show_heightmap: bool,
|
||||
) {
|
||||
if !self.needs_upload
|
||||
&& heightmap == &self.last_heightmap[..]
|
||||
&& terrain == &self.last_terrain[..]
|
||||
&& show_heightmap == self.last_show_heightmap
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.last_heightmap.copy_from_slice(heightmap);
|
||||
self.last_terrain.copy_from_slice(terrain);
|
||||
self.last_show_heightmap = show_heightmap;
|
||||
self.needs_upload = false;
|
||||
|
||||
let min_height = *heightmap.iter().min().unwrap();
|
||||
let max_height = *heightmap.iter().max().unwrap();
|
||||
|
||||
let packed_heightmap = Self::pack_heightmap(heightmap);
|
||||
self.queue
|
||||
.write_buffer(&self.heightmap_buffer, 0, as_u8_slice(&packed_heightmap));
|
||||
|
||||
let packed_terrain = Self::pack_terrain(terrain);
|
||||
self.queue
|
||||
.write_buffer(&self.terrain_buffer, 0, as_u8_slice(&packed_terrain));
|
||||
|
||||
let uniforms = Uniforms {
|
||||
min_height: min_height as i32,
|
||||
max_height: max_height as i32,
|
||||
show_heightmap: if show_heightmap { 1 } else { 0 },
|
||||
tile_size: 128,
|
||||
};
|
||||
self.queue
|
||||
.write_buffer(&self.uniform_buffer, 0, as_u8_value(&uniforms));
|
||||
|
||||
let mut encoder = self.device.create_command_encoder(&Default::default());
|
||||
|
||||
{
|
||||
let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("terrain_compute_pass"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
cpass.set_pipeline(&self.compute_pipeline);
|
||||
cpass.set_bind_group(0, &self.bind_group, &[]);
|
||||
cpass.dispatch_workgroups(8, 8, 1);
|
||||
}
|
||||
|
||||
let bytes_per_row = self.width * 4;
|
||||
encoder.copy_buffer_to_texture(
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &self.output_buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(bytes_per_row),
|
||||
rows_per_image: Some(self.height),
|
||||
},
|
||||
},
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &self.output_texture,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
|
||||
self.queue.submit(Some(encoder.finish()));
|
||||
}
|
||||
|
||||
pub fn register_texture(&mut self, renderer: &mut egui_wgpu::Renderer) {
|
||||
let id = renderer.register_native_texture(
|
||||
&self.device,
|
||||
&self.output_texture_view,
|
||||
wgpu::FilterMode::Linear,
|
||||
);
|
||||
self.texture_id = Some(id);
|
||||
}
|
||||
|
||||
pub fn update_texture(&mut self, renderer: &mut egui_wgpu::Renderer) {
|
||||
if let Some(id) = self.texture_id {
|
||||
renderer.update_egui_texture_from_wgpu_texture(
|
||||
&self.device,
|
||||
&self.output_texture_view,
|
||||
wgpu::FilterMode::Linear,
|
||||
id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mark_dirty(&mut self) {
|
||||
self.needs_upload = true;
|
||||
}
|
||||
}
|
||||
114
gui/src/renderer/shaders.wgsl
Normal file
114
gui/src/renderer/shaders.wgsl
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
struct Uniforms {
|
||||
min_height: i32,
|
||||
max_height: i32,
|
||||
show_heightmap: u32,
|
||||
tile_size: u32,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
|
||||
@group(0) @binding(1) var<storage, read> heightmap_packed: array<u32>;
|
||||
@group(0) @binding(2) var<storage, read> terrain_packed: array<u32>;
|
||||
@group(0) @binding(3) var<storage, read_write> output: array<u32>;
|
||||
|
||||
fn unpack_heightmap(data: array<u32>, index: u32) -> i32 {
|
||||
let packed = data[index / 2u];
|
||||
var val: i32;
|
||||
if index % 2u == 0u {
|
||||
val = i32(packed & 0xFFFFu);
|
||||
} else {
|
||||
val = i32(packed >> 16u);
|
||||
}
|
||||
if val & 0x8000 != 0 {
|
||||
val = val | 0xFFFF0000;
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
fn unpack_terrain(data: array<u32>, index: u32) -> u32 {
|
||||
let packed = data[index / 4u];
|
||||
let shift = (index % 4u) * 8u;
|
||||
return (packed >> shift) & 0xFFu;
|
||||
}
|
||||
|
||||
fn terrain_color(id: u32, height_factor: f32) -> vec3<f32> {
|
||||
var color: vec3<f32>;
|
||||
switch id {
|
||||
case 0u: {
|
||||
color = vec3<f32>(194.0, 178.0, 128.0);
|
||||
}
|
||||
case 1u: {
|
||||
color = vec3<f32>(124.0, 189.0, 107.0);
|
||||
}
|
||||
case 2u: {
|
||||
color = vec3<f32>(86.0, 140.0, 74.0);
|
||||
}
|
||||
case 3u: {
|
||||
color = vec3<f32>(128.0, 128.0, 128.0);
|
||||
}
|
||||
case 4u: {
|
||||
color = vec3<f32>(227.0, 212.0, 160.0);
|
||||
}
|
||||
case 5u: {
|
||||
color = vec3<f32>(72.0, 107.0, 75.0);
|
||||
}
|
||||
case 6u: {
|
||||
color = vec3<f32>(64.0, 128.0, 255.0);
|
||||
}
|
||||
default: {
|
||||
color = vec3<f32>(128.0, 128.0, 128.0);
|
||||
}
|
||||
}
|
||||
return color * height_factor;
|
||||
}
|
||||
|
||||
fn height_color(norm: f32) -> vec3<f32> {
|
||||
var color: vec3<f32>;
|
||||
if norm < 0.25 {
|
||||
let t = norm / 0.25;
|
||||
color = mix(vec3<f32>(0.0, 0.0, 255.0), vec3<f32>(0.0, 255.0, 255.0), t);
|
||||
} else if norm < 0.5 {
|
||||
let t = (norm - 0.25) / 0.25;
|
||||
color = mix(vec3<f32>(0.0, 255.0, 255.0), vec3<f32>(0.0, 255.0, 0.0), t);
|
||||
} else if norm < 0.75 {
|
||||
let t = (norm - 0.5) / 0.25;
|
||||
color = mix(vec3<f32>(0.0, 255.0, 0.0), vec3<f32>(255.0, 255.0, 0.0), t);
|
||||
} else {
|
||||
let t = (norm - 0.75) / 0.25;
|
||||
color = mix(vec3<f32>(255.0, 255.0, 0.0), vec3<f32>(255.0, 0.0, 0.0), t);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
@compute @workgroup_size(16, 16, 1)
|
||||
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
if id.x >= uniforms.tile_size || id.y >= uniforms.tile_size {
|
||||
return;
|
||||
}
|
||||
|
||||
let index = id.y * uniforms.tile_size + id.x;
|
||||
|
||||
let height = unpack_heightmap(heightmap_packed, index);
|
||||
let terrain_id = unpack_terrain(terrain_packed, index);
|
||||
|
||||
let height_factor = 0.7 + 0.3 * ((f32(height) + 64.0) / 384.0);
|
||||
|
||||
var color: vec3<f32>;
|
||||
if uniforms.show_heightmap != 0u {
|
||||
let height_range = f32(uniforms.max_height - uniforms.min_height);
|
||||
var norm: f32;
|
||||
if height_range <= 0.0 {
|
||||
norm = 0.5;
|
||||
} else {
|
||||
norm = (f32(height) - f32(uniforms.min_height)) / height_range;
|
||||
}
|
||||
norm = clamp(norm, 0.0, 1.0);
|
||||
color = height_color(norm);
|
||||
} else {
|
||||
color = terrain_color(terrain_id, height_factor);
|
||||
}
|
||||
|
||||
output[index] = (u32(color.r) & 0xFFu) |
|
||||
((u32(color.g) & 0xFFu) << 8u) |
|
||||
((u32(color.b) & 0xFFu) << 16u) |
|
||||
0xFF000000u;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue