Add operations (erode, fill, flood, pencil), filter system, layer system with exporters, and WorldPainter .world import

- New operations: ErodeOperation (thermal erosion), FillOperation (flood-fill), FloodOperation (hard replace), PencilOperation (single-point)
- Filter system: Filter trait with TerrainFilter, HeightFilter, SlopeFilter, CompositeFilter, InvertFilter, FilteredOperation wrapper
- Layer system: Layer trait + 6 implementations (Caves, River, Frost, Trees, Biome, Resources) + layer data helpers on Tile
- Layer exporters: LayerExport trait + 6 implementations that modify blocks during export (caves carve air, rivers create water channels, frost adds snow/ice, trees place logs, resources add ores, biomes set biome palette)
- WorldPainter .world import: Java serialization stream parser with full TC_* token support, tile extraction, format auto-detection
This commit is contained in:
loki5512344 2026-06-15 19:41:47 +02:00
parent 2b2f968d70
commit fa05187f1c
16 changed files with 2218 additions and 13 deletions

1
Cargo.lock generated
View file

@ -3571,6 +3571,7 @@ name = "terrafier-core"
version = "0.1.0"
dependencies = [
"bincode",
"flate2",
"image",
"log",
"rayon",

View file

@ -36,6 +36,7 @@ log = "0.4"
env_logger = "0.11"
serde-big-array = "0.5"
bincode = "1.3"
flate2 = "1.1"
[workspace.metadata.dist]
# Инициализируется при первом запуске cargo dist init

View file

@ -20,6 +20,7 @@ serde-big-array.workspace = true
bincode.workspace = true
image.workspace = true
flate2.workspace = true
[dev-dependencies]
tempfile = "3"

View file

@ -3,6 +3,7 @@
use std::path::Path;
use thiserror::Error;
use crate::io::layer_export::LayerExport;
use crate::io::minecraft::MinecraftIOError;
use crate::model::world::World;
@ -26,6 +27,16 @@ pub fn export_to_save(world: &World, output_path: &Path) -> Result<()> {
Ok(())
}
/// Export a Terrafier World to a Minecraft save directory, applying layer exporters.
pub fn export_to_save_with_layers(
world: &World,
output_path: &Path,
layer_exporters: &[&dyn LayerExport],
) -> Result<()> {
crate::io::minecraft::save_world_with_layers(world, output_path, layer_exporters)?;
Ok(())
}
/// Render a Terrafier world to a PNG image (top-down view).
pub fn render_to_image(world: &World, output_path: &Path, scale: u32) -> Result<()> {
// Determine bounds from tiles

View file

@ -4,6 +4,7 @@ use std::path::Path;
use thiserror::Error;
use crate::io::minecraft::MinecraftIOError;
use crate::io::wp_import;
use crate::model::world::World;
#[derive(Error, Debug)]
@ -72,5 +73,10 @@ pub fn import(path: &Path) -> Result<World> {
}
}
if wp_import::is_world_file(path) {
return wp_import::import_world_file(path)
.map_err(|e| ImportError::UnsupportedFormat(e.to_string()));
}
Err(ImportError::UnsupportedFormat(path_str))
}

313
core/src/io/layer_export.rs Normal file
View file

@ -0,0 +1,313 @@
//! 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 % 2 == 0 { 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 % 2 == 0 { byte >> 4 } else { byte & 0xF };
if value > 0 && block_name == "minecraft:stone" && y > -60 {
let ore = match value {
1 => "minecraft:coal_ore",
2 => "minecraft:iron_ore",
3 => "minecraft:copper_ore",
4 => "minecraft:gold_ore",
5 => "minecraft:redstone_ore",
6 => "minecraft:lapis_ore",
7 => "minecraft:diamond_ore",
8 => "minecraft:emerald_ore",
_ => return None,
};
Some(ore)
} else {
None
}
} else {
None
}
}
}
pub struct BiomeLayerExport;
impl LayerExport for BiomeLayerExport {
fn layer_id(&self) -> u32 {
LAYER_BIOME
}
fn modify_block(
&self,
_tile: &Tile,
_local_x: usize,
_local_z: usize,
_y: i32,
_surface_y: i32,
_block_name: &str,
) -> Option<&'static str> {
None
}
fn biome_name(&self, tile: &Tile, local_x: usize, local_z: usize) -> Option<&'static str> {
let idx = local_z * TILE_SIZE + local_x;
let data = tile.get_layer_data(LAYER_BIOME)?;
if let LayerBuffer::Byte(bytes) = data {
Some(match bytes[idx] {
0 => "minecraft:plains",
1 => "minecraft:desert",
2 => "minecraft:forest",
3 => "minecraft:taiga",
4 => "minecraft:swamp",
5 => "minecraft:snowy_plains",
6 => "minecraft:jungle",
7 => "minecraft:badlands",
8 => "minecraft:ocean",
9 => "minecraft:river",
10 => "minecraft:mushroom_fields",
_ => "minecraft:plains",
})
} else {
None
}
}
}

View file

@ -11,6 +11,7 @@ use terrafier_nbt::io::reader::read_gzip;
use crate::model::dimension::Dimension;
use crate::model::platform::Platform;
use crate::io::layer_export::{apply_layers, biome_name, LayerExport};
use crate::model::tile::Tile;
use crate::model::world::World;
@ -496,6 +497,199 @@ fn build_chunk_nbt(
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<()> {
use std::collections::BTreeMap;
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: BTreeMap<(i32, i32), Vec<(&(i32, i32), &Tile)>> = BTreeMap::new();
for (key, tile) in &dim.tiles {
let (tx, tz) = key;
let rx = tx >> 2;
let rz = tz >> 2;
regions.entry((rx, rz)).or_default().push((key, tile));
}
for ((rx, rz), tile_refs) in &regions {
let mut region = terrafier_fastanvil::io::region::Region::new(*rx, *rz);
for (_key, tile) in tile_refs {
for chunk_lx in 0..8usize {
for chunk_lz in 0..8usize {
let chunk_x = tile.x * 8 + chunk_lx as i32;
let chunk_z = tile.z * 8 + chunk_lz as i32;
let region_local_x = (chunk_x & 31) as u8;
let region_local_z = (chunk_z & 31) as u8;
let chunk_data = build_chunk_nbt_with_layers(chunk_x, chunk_z, tile, chunk_lx, chunk_lz, layer_exporters)?;
if chunk_data.is_empty() {
continue;
}
region.set_chunk_data(region_local_x, region_local_z, chunk_data);
}
}
}
let region_bytes = region.to_bytes()?;
let file_name = format!("r.{}.{}.mca", rx, rz);
fs::write(output_path.join("region").join(&file_name), &region_bytes)?;
}
Ok(())
}
fn parse_level_dat(root: &terrafier_nbt::Tag) -> (String, u64, i32) {
let default = || ("Unknown".to_string(), 0u64, 3954i32);
match root {

View file

@ -3,4 +3,6 @@
pub mod binary;
pub mod export;
pub mod import;
pub mod layer_export;
pub mod minecraft;
pub mod wp_import;

1012
core/src/io/wp_import.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,13 +0,0 @@
pub trait Layer: Send + Sync {
fn id(&self) -> &'static str;
fn name(&self) -> &'static str;
fn data_size(&self) -> DataSize;
fn priority(&self) -> i32;
}
pub enum DataSize {
Bit,
Nibble,
Byte,
Int,
}

View file

@ -0,0 +1,49 @@
use super::{DataSize, Layer};
pub struct CavesLayer;
impl Layer for CavesLayer {
fn id(&self) -> &'static str { "caves" }
fn name(&self) -> &'static str { "Caves" }
fn data_size(&self) -> DataSize { DataSize::Nibble }
fn priority(&self) -> i32 { 10 }
}
pub struct RiverLayer;
impl Layer for RiverLayer {
fn id(&self) -> &'static str { "river" }
fn name(&self) -> &'static str { "River" }
fn data_size(&self) -> DataSize { DataSize::Byte }
fn priority(&self) -> i32 { 20 }
}
pub struct FrostLayer;
impl Layer for FrostLayer {
fn id(&self) -> &'static str { "frost" }
fn name(&self) -> &'static str { "Frost" }
fn data_size(&self) -> DataSize { DataSize::Bit }
fn priority(&self) -> i32 { 30 }
}
pub struct TreesLayer;
impl Layer for TreesLayer {
fn id(&self) -> &'static str { "trees" }
fn name(&self) -> &'static str { "Trees" }
fn data_size(&self) -> DataSize { DataSize::Byte }
fn priority(&self) -> i32 { 40 }
}
pub struct BiomeLayer;
impl Layer for BiomeLayer {
fn id(&self) -> &'static str { "biome" }
fn name(&self) -> &'static str { "Biome" }
fn data_size(&self) -> DataSize { DataSize::Byte }
fn priority(&self) -> i32 { 50 }
}
pub struct ResourcesLayer;
impl Layer for ResourcesLayer {
fn id(&self) -> &'static str { "resources" }
fn name(&self) -> &'static str { "Resources" }
fn data_size(&self) -> DataSize { DataSize::Nibble }
fn priority(&self) -> i32 { 60 }
}

View file

@ -0,0 +1,24 @@
//! Layer system — defines the Layer trait and built-in layer types.
pub mod builtin;
pub trait Layer: Send + Sync {
fn id(&self) -> &'static str;
fn name(&self) -> &'static str;
fn data_size(&self) -> DataSize;
fn priority(&self) -> i32;
}
pub enum DataSize {
Bit,
Nibble,
Byte,
Int,
}
pub const LAYER_CAVES: u32 = 1;
pub const LAYER_RIVER: u32 = 2;
pub const LAYER_FROST: u32 = 3;
pub const LAYER_TREES: u32 = 4;
pub const LAYER_BIOME: u32 = 5;
pub const LAYER_RESOURCES: u32 = 6;

View file

@ -59,4 +59,26 @@ impl Tile {
pub fn set_terrain(&mut self, lx: usize, lz: usize, terrain: u8) {
self.terrain[lz * TILE_SIZE + lx] = terrain;
}
/// Get layer data buffer for a given layer ID.
pub fn get_layer_data(&self, layer_id: u32) -> Option<&LayerBuffer> {
self.layer_data.get(&layer_id)
}
/// Set layer data buffer for a given layer ID.
pub fn set_layer_data(&mut self, layer_id: u32, data: LayerBuffer) {
self.layer_data.insert(layer_id, data);
}
/// Ensure a layer buffer exists, creating an empty one if needed.
pub fn ensure_layer(&mut self, layer_id: u32, data_size: crate::model::layers::DataSize) -> &mut LayerBuffer {
use crate::model::layers::DataSize;
let total = TILE_SIZE * TILE_SIZE;
self.layer_data.entry(layer_id).or_insert_with(|| match data_size {
DataSize::Bit => LayerBuffer::Bit(vec![0u64; (total + 63) / 64]),
DataSize::Nibble => LayerBuffer::Nibble(vec![0u8; (total + 1) / 2]),
DataSize::Byte => LayerBuffer::Byte(vec![0u8; total]),
DataSize::Int => LayerBuffer::Int(vec![0i32; total]),
})
}
}

219
core/src/ops/filters.rs Normal file
View file

@ -0,0 +1,219 @@
//! 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, 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
}
}
/// Only apply where slope (max height difference to 4 neighbors) is within a range.
pub struct SlopeFilter {
pub min: f64,
pub max: f64,
}
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;
for (dx, dz) in [(0, -1), (0, 1), (-1, 0), (1, 0)] {
let nx = local_x as i32 + dx;
let nz = local_z as i32 + dz;
if nx >= 0 && nx < TILE_SIZE as i32 && nz >= 0 && nz < TILE_SIZE as i32 {
let nh = tile.get_height(nx as usize, nz as usize);
let diff = (center - nh).abs();
if diff > max_diff {
max_diff = diff;
}
}
}
let slope = max_diff as f64;
slope >= self.min && slope <= self.max
}
}
/// How multiple filters are combined.
pub enum FilterCombinator {
And,
Or,
}
/// Combine multiple filters with AND/OR logic.
pub struct CompositeFilter {
pub filters: Vec<Box<dyn Filter>>,
pub combinator: FilterCombinator,
}
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
.filters
.iter()
.all(|f| f.should_apply(tile, local_x, local_z)),
FilterCombinator::Or => self
.filters
.iter()
.any(|f| f.should_apply(tile, local_x, local_z)),
}
}
}
/// Negate a filter.
pub struct InvertFilter {
pub filter: Box<dyn Filter>,
}
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)
}
}
/// 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.
///
/// Inverse restores all cells from the snapshot.
pub struct FilteredOperation {
pub operation: Box<dyn Operation>,
pub filter: Box<dyn Filter>,
pub tile_x: i32,
pub tile_z: i32,
pub center_x: u32,
pub center_z: u32,
pub radius: u32,
pub before_snapshot: OnceLock<Vec<(usize, i16)>>,
}
impl Operation for FilteredOperation {
fn name(&self) -> &'static str {
self.operation.name()
}
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.get(&(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 snap = 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 idx = (az as usize) * TILE_SIZE + (ax as usize);
snap.push((idx, tile.heightmap[idx]));
}
}
let _ = self.before_snapshot.set(snap.clone());
snap
} else {
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)).ok_or(
OperationError::OutOfBounds {
tx: self.tile_x,
tz: self.tile_z,
x: 0,
z: 0,
},
)?;
for &(idx, original_h) in &snapshot {
let lx = idx % TILE_SIZE;
let lz = idx / TILE_SIZE;
if !self.filter.should_apply(tile, lx, lz) {
tile.heightmap[idx] = original_h;
}
}
Ok(())
}
fn inverse(&self) -> Box<dyn Operation> {
let snapshot = self.before_snapshot.get().cloned().unwrap_or_default();
Box::new(RestoreHeightsOperation {
tile_x: self.tile_x,
tile_z: self.tile_z,
snapshot,
})
}
}

View file

@ -1,4 +1,5 @@
//! Editing operations — terrain modification, brush operations, height maps.
pub mod filters;
pub mod heightmap;
pub mod operations;

View file

@ -2,6 +2,7 @@
//!
//! Each operation implements the Operation trait and supports undo.
use std::collections::VecDeque;
use std::sync::OnceLock;
use std::sync::Arc;
@ -443,6 +444,367 @@ impl Operation for SmoothOperation {
}
}
/// 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>>,