Fix critical structural issues: undo/redo, surface import, brush strength, i18n macro, level.dat export, GPU fallback, Nether/End support, dead code removal, dim serde, CPU perf, wasm-host, GPU constants, clippy warnings

- Replace OnceLock with Mutex<Option> for snapshot system in all 9 operations (undo/redo now works after redo)
- Fix import surface detection: scan actual packed block data for highest solid block instead of always using +15
- Apply brush strength threshold (< 0.3) in PaintOperation for natural edge falloff
- Remove broken t!() macro variant with variable substitution
- Complete level.dat with RandomSeed, GameType, Difficulty, Spawn, Time, DataPacks, etc.
- GPU viewport falls back to CPU renderer for multi-tile worlds
- Export supports Nether (DIM-1/region) and End (DIM1/region) dimension subdirectories
- Remove dead FloodOperation (duplicate of PaintOperation)
- Add log::warn for malformed dimension tile keys in serde
- Reduce CPU viewport base_size from 64 to 32 (4x fewer pixels)
- Simplify wasm-host host_get_seed to direct cast
- Add named constants for GPU buffer sizes
- Fix unused import warning in cli/main.rs
- cargo fmt
This commit is contained in:
loki5512344 2026-07-04 11:44:34 +02:00
parent 18613260a8
commit 2419b603c1
24 changed files with 339 additions and 271 deletions

23
PLAN.md
View file

@ -125,3 +125,26 @@ struct World {
seed: u64,
}
```
## Известные проблемы (требуют фикса)
### Критические
1. **GPU рендерит только 1 тайл** — `GpuRenderer` 128×128, а вьюпорт показывает мульти-тайловый мир. `gpu.rs` рисует один тайл, остальные пустые.
2. **Undo/redo сломан** — `OnceLock` в операциях не позволяет повторно захватить снепшот при redo. После undo→redo мир ломается.
3. **Импорт высот неверный** — `reader.rs:122` всегда ставит surface_y = section_y * 16 + 15, игнорируя реальную высоту поверхности.
4. **Brush strength не влияет на Paint** — `apply_brush_terrain()` игнорирует силу кисти, кроме проверки на 0.
5. **Макрос `t!()` сломан** — `i18n/src/lib.rs:61`: невалидный каст, макрос с переменными не компилируется.
### Существенные
6. **level.dat при экспорте неполный** — нет `GameRules`, `Difficulty`, `Spawn`, `Time`, `RandomSeed`.
7. **Нет поддержки Nether/End** — экспорт всегда в `region/`, импорт создаёт только `"overworld"`.
8. **`FloodOperation` — мёртвый код** — дубликат `PaintOperation`, нигде не используется.
9. **`Dimension` сериализация хрупкая** — строковые ключи `"{tx},{tz}"`, битые ключи молча дропаются.
10. **CPU-вьюпорт рендерит всё каждый кадр** — полный перерендер при каждом `update()`.
11. **`wasm-host` `host_get_seed` бессмысленный** — разбивает u64 на lo/hi 32-bit и собирает обратно.
### Архитектурные
12. **Только 1 тест на 93 .rs файла** — операции, импорт, экспорт, слои без покрытия.
13. **Экспорт блоков примитивный** — 9 типов блоков, без свойств (snowy, waterlogged).
14. **`data_version` хардкод** — всегда экспортит 3954, игнорируя версию исходного мира.
15. **GPU buffer sizes хардкод** — `8192 * 4` и `4096 * 4` без Named constants.

View file

@ -33,7 +33,10 @@ pub fn cmd_export(
if validate || dry_run {
if dry_run {
log::info!("{}", i18n::translate_with("cli.dry_run", &[("action", "export")]));
log::info!(
"{}",
i18n::translate_with("cli.dry_run", &[("action", "export")])
);
}
output::print_result(
format,

View file

@ -24,7 +24,10 @@ pub fn cmd_import(
if validate || dry_run {
if dry_run {
log::info!("{}", i18n::translate_with("cli.dry_run", &[("action", "import")]));
log::info!(
"{}",
i18n::translate_with("cli.dry_run", &[("action", "import")])
);
}
terrafier_core::io::import::validate_save(input_path)?;
output::print_result(
@ -54,10 +57,13 @@ pub fn cmd_import(
let bar = progress::new_bar(region_count.max(1) as u64, &importing_msg);
let world = terrafier_core::io::import::import(input_path)?;
let tile_count: usize = world.dimensions.iter().map(|d| d.tiles.len()).sum();
let import_msg = i18n::translate_with("cli.import.complete", &[
("tiles", &tile_count.to_string()),
("dimensions", &world.dimensions.len().to_string()),
]);
let import_msg = i18n::translate_with(
"cli.import.complete",
&[
("tiles", &tile_count.to_string()),
("dimensions", &world.dimensions.len().to_string()),
],
);
progress::finish_with(&import_msg, bar);
let output_path = Path::new(&args.output);

View file

@ -33,7 +33,10 @@ pub fn cmd_render(
if validate || dry_run {
if dry_run {
log::info!("{}", i18n::translate_with("cli.dry_run", &[("action", "render")]));
log::info!(
"{}",
i18n::translate_with("cli.dry_run", &[("action", "render")])
);
}
output::print_result(
format,
@ -50,9 +53,10 @@ pub fn cmd_render(
let bar = progress::new_bar(tile_count as u64, i18n::t!("cli.render.rendering"));
terrafier_core::io::export::render_to_image(&world, &args.output, args.scale)?;
let saved_msg = i18n::translate_with("cli.render.saved", &[
("path", &args.output.display().to_string()),
]);
let saved_msg = i18n::translate_with(
"cli.render.saved",
&[("path", &args.output.display().to_string())],
);
progress::finish_with(&saved_msg, bar);
log::info!("Preview rendered to: {}", args.output.display());

View file

@ -45,11 +45,15 @@ pub fn cmd_new(
let spinner = progress::new_spinner(i18n::t!("cli.new.creating"));
let world = World::new(&args.name, args.seed);
let tile_count = world.dimensions.iter().map(|d| d.tiles.len()).sum::<usize>();
let created_msg = i18n::translate_with("cli.new.created", &[
("name", &args.name),
("tiles", &tile_count.to_string()),
]);
let tile_count = world
.dimensions
.iter()
.map(|d| d.tiles.len())
.sum::<usize>();
let created_msg = i18n::translate_with(
"cli.new.created",
&[("name", &args.name), ("tiles", &tile_count.to_string())],
);
progress::finish_with(&created_msg, spinner);
let world_dir = args.output.join(&args.name);

View file

@ -1,5 +1,4 @@
use clap::Parser;
use std::path::PathBuf;
mod cmds;
mod util;
@ -43,7 +42,7 @@ enum Commands {
#[cfg(feature = "scripting")]
Run {
/// Path to .rhai script file
script: PathBuf,
script: std::path::PathBuf,
},
}

View file

@ -37,10 +37,10 @@ fn print_human(result: &Value) {
let tiles = result["tiles"].as_u64().unwrap_or(0);
println!(
"{}",
i18n::translate_with("cli.new.created", &[
("name", name),
("tiles", &tiles.to_string()),
])
i18n::translate_with(
"cli.new.created",
&[("name", name), ("tiles", &tiles.to_string()),]
)
);
let _ = seed;
}
@ -48,10 +48,10 @@ fn print_human(result: &Value) {
let tiles = result["tiles"].as_u64().unwrap_or(0);
println!(
"{}",
i18n::translate_with("cli.export.complete", &[
("regions", "?"),
("chunks", &tiles.to_string()),
])
i18n::translate_with(
"cli.export.complete",
&[("regions", "?"), ("chunks", &tiles.to_string()),]
)
);
}
"imported" => {
@ -60,10 +60,13 @@ fn print_human(result: &Value) {
let dims = result["dimensions"].as_u64().unwrap_or(0);
println!(
"{}",
i18n::translate_with("cli.import.complete", &[
("tiles", &tiles.to_string()),
("dimensions", &dims.to_string()),
])
i18n::translate_with(
"cli.import.complete",
&[
("tiles", &tiles.to_string()),
("dimensions", &dims.to_string()),
]
)
);
let _ = name;
}
@ -71,9 +74,7 @@ fn print_human(result: &Value) {
let output = result["output"].as_str().unwrap_or("?");
println!(
"{}",
i18n::translate_with("cli.render.saved", &[
("path", output),
])
i18n::translate_with("cli.render.saved", &[("path", output),])
);
}
"validated" => {
@ -125,17 +126,15 @@ fn print_human_info(result: &Value) {
if let Some(dims) = result.get("dimensions").and_then(|v| v.as_array()) {
println!(
" {}",
i18n::translate_with("cli.info.dimensions", &[
("dimensions", &dims.len().to_string()),
])
i18n::translate_with(
"cli.info.dimensions",
&[("dimensions", &dims.len().to_string()),]
)
);
for dim in dims {
let dim_name = dim["name"].as_str().unwrap_or("?");
let dim_tiles = dim["tiles"].as_u64().unwrap_or(0);
println!(
" - {} ({} tiles)",
dim_name, dim_tiles
);
println!(" - {} ({} tiles)", dim_name, dim_tiles);
}
}
if let Some(tiles) = result.get("total_tiles").and_then(|v| v.as_u64()) {

View file

@ -14,18 +14,21 @@ 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"))?;
fn dim_region_path(output_path: &Path, dim_name: &str) -> std::path::PathBuf {
match dim_name {
"the_nether" => output_path.join("DIM-1").join("region"),
"the_end" => output_path.join("DIM1").join("region"),
_ => 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(()),
};
fn write_dimension(
dim: &crate::model::dimension::Dimension,
output_path: &Path,
build_chunk: &dyn Fn(i32, i32, &Tile, usize, usize) -> Result<Vec<u8>>,
) -> Result<()> {
let region_path = dim_region_path(output_path, &dim.name);
fs::create_dir_all(&region_path)?;
let mut regions: RegionTiles = BTreeMap::new();
for (key, tile) in &dim.tiles {
@ -47,7 +50,7 @@ pub fn save_world(world: &World, output_path: &Path) -> Result<()> {
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)?;
let chunk_data = build_chunk(chunk_x, chunk_z, tile, chunk_lx, chunk_lz)?;
if chunk_data.is_empty() {
continue;
@ -60,7 +63,22 @@ pub fn save_world(world: &World, output_path: &Path) -> Result<()> {
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)?;
fs::write(region_path.join(&file_name), &region_bytes)?;
}
Ok(())
}
/// Save a Terrafier World to a Minecraft save directory.
pub fn save_world(world: &World, output_path: &Path) -> Result<()> {
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)?;
for dim in &world.dimensions {
write_dimension(dim, output_path, &|cx, cz, tile, clx, clz| {
build_chunk_nbt(cx, cz, tile, clx, clz)
})?;
}
Ok(())
@ -72,58 +90,14 @@ pub fn save_world_with_layers(
output_path: &Path,
layer_exporters: &[&dyn LayerExport],
) -> Result<()> {
fs::create_dir_all(output_path.join("region"))?;
let level_tag = build_level_dat(world)?;
let level_bytes = terrafier_nbt::io::writer::to_gzip_bytes(&level_tag)?;
fs::write(output_path.join("level.dat"), &level_bytes)?;
let dim = match world.dimensions.first() {
Some(d) => d,
None => return Ok(()),
};
let mut regions: RegionTiles = BTreeMap::new();
for (key, tile) in &dim.tiles {
let (tx, tz) = key;
let rx = tx >> 2;
let rz = tz >> 2;
regions.entry((rx, rz)).or_default().push((key, tile));
}
for ((rx, rz), tile_refs) in &regions {
let mut region = terrafier_fastanvil::io::region::Region::new(*rx, *rz);
for (_key, tile) in tile_refs {
for chunk_lx in 0..8usize {
for chunk_lz in 0..8usize {
let chunk_x = tile.x * 8 + chunk_lx as i32;
let chunk_z = tile.z * 8 + chunk_lz as i32;
let region_local_x = (chunk_x & 31) as u8;
let region_local_z = (chunk_z & 31) as u8;
let chunk_data = build_chunk_nbt_with_layers(
chunk_x,
chunk_z,
tile,
chunk_lx,
chunk_lz,
layer_exporters,
)?;
if chunk_data.is_empty() {
continue;
}
region.set_chunk_data(region_local_x, region_local_z, chunk_data);
}
}
}
let region_bytes = region.to_bytes()?;
let file_name = format!("r.{}.{}.mca", rx, rz);
fs::write(output_path.join("region").join(&file_name), &region_bytes)?;
for dim in &world.dimensions {
write_dimension(dim, output_path, &|cx, cz, tile, clx, clz| {
build_chunk_nbt_with_layers(cx, cz, tile, clx, clz, layer_exporters)
})?;
}
Ok(())
@ -135,6 +109,24 @@ fn build_level_dat(world: &World) -> Result<terrafier_nbt::Tag> {
data.insert("LevelName".into(), Tag::String(world.name.clone()));
data.insert("DataVersion".into(), Tag::Int(data_version()));
data.insert("version".into(), Tag::Int(19133));
data.insert("RandomSeed".into(), Tag::Long(world.seed as i64));
data.insert("GameType".into(), Tag::Int(1));
data.insert("hardcore".into(), Tag::Byte(0));
data.insert("Difficulty".into(), Tag::Byte(2));
data.insert("DifficultyLocked".into(), Tag::Byte(0));
data.insert("SpawnX".into(), Tag::Int(0));
data.insert("SpawnY".into(), Tag::Int(64));
data.insert("SpawnZ".into(), Tag::Int(0));
data.insert("Time".into(), Tag::Long(0));
data.insert("DayTime".into(), Tag::Long(1000));
data.insert("allowCommands".into(), Tag::Byte(1));
data.insert("initialized".into(), Tag::Byte(1));
data.insert("LastPlayed".into(), Tag::Long(0));
let mut data_packs = HashMap::new();
data_packs.insert("Enabled".into(), Tag::List(vec![]));
data_packs.insert("Disabled".into(), Tag::List(vec![]));
data.insert("DataPacks".into(), Tag::Compound(data_packs));
let mut world_gen = HashMap::new();
world_gen.insert("seed".into(), Tag::Long(world.seed as i64));

View file

@ -86,41 +86,74 @@ pub fn load_save(path: &Path) -> Result<World> {
let mut sorted: Vec<_> = chunk.sections.iter().collect();
sorted.sort_by_key(|b| std::cmp::Reverse(b.section_y));
for section in &sorted {
'section_loop: 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"
)
let non_air_indices: Vec<usize> = section
.palette
.iter()
.enumerate()
.filter(|(_, p)| {
p.get("Name").is_some_and(|n| {
matches!(
n,
terrafier_nbt::Tag::String(s)
if s != "minecraft:air"
)
})
})
});
.map(|(i, _)| i)
.collect();
if !has_blocks {
if non_air_indices.is_empty() {
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 bpe = std::cmp::max(
4,
(section.palette.len() as f64).log2().ceil() as usize,
);
for dy in (0..16usize).rev() {
let block_index = dy * 256 + lz * 16 + lx;
let bit_pos = block_index * bpe;
let long_idx = bit_pos / 64;
let bit_off = bit_pos % 64;
if let Some(&word) = section.block_data.get(long_idx) {
let palette_index = if bit_off + bpe <= 64 {
let mask = (1u64 << bpe) - 1;
((word as u64 >> bit_off) & mask) as usize
} else {
let low_bits = 64 - bit_off;
let low = ((word as u64) >> bit_off) as usize;
let high_bits = bit_off + bpe - 64;
let high = section
.block_data
.get(long_idx + 1)
.map(|&w| {
(w as u64 & ((1u64 << high_bits) - 1))
as usize
})
.unwrap_or(0);
low | (high << low_bits)
};
if non_air_indices.contains(&palette_index) {
surface_y = Some(
(section.section_y as i32) * 16 + dy as i32,
);
break 'section_loop;
}
}
}
}
}

View file

@ -27,8 +27,8 @@ 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 config = bincode::config::standard();
let bytes =
bincode::serde::encode_to_vec(world, config).map_err(|e| BinaryError::Serialize(e.to_string()))?;
let bytes = bincode::serde::encode_to_vec(world, config)
.map_err(|e| BinaryError::Serialize(e.to_string()))?;
std::fs::write(path, &bytes)?;
Ok(())
@ -38,9 +38,8 @@ pub fn save_binary(world: &World, path: &Path) -> Result<()> {
pub fn load_binary(path: &Path) -> Result<World> {
let bytes = std::fs::read(path)?;
let config = bincode::config::standard();
let (world, _): (World, usize) =
bincode::serde::decode_from_slice(&bytes, config)
.map_err(|e| BinaryError::Deserialize(e.to_string()))?;
let (world, _): (World, usize) = bincode::serde::decode_from_slice(&bytes, config)
.map_err(|e| BinaryError::Deserialize(e.to_string()))?;
Ok(world)
}

View file

@ -55,6 +55,8 @@ impl<'de> Deserialize<'de> for Dimension {
&& let (Ok(tx), Ok(tz)) = (tx_s.parse::<i32>(), tz_s.parse::<i32>())
{
tiles.insert((tx, tz), tile);
} else {
log::warn!("Skipping dimension tile with malformed key: {}", key);
}
}
Ok(Self {

View file

@ -3,7 +3,7 @@ use crate::model::dimension::Dimension;
use crate::model::tile::{TILE_SIZE, Tile};
use crate::model::types::Terrain;
use crate::ops::operations::{Operation, OperationError, RestoreHeightsOperation};
use std::sync::OnceLock;
use std::sync::Mutex;
/// Determines whether a cell should be modified by an operation.
pub trait Filter: Send + Sync {
fn name(&self) -> &'static str;
@ -121,7 +121,7 @@ pub struct FilteredOperation {
pub center_x: u32,
pub center_z: u32,
pub radius: u32,
pub before_snapshot: OnceLock<Vec<(usize, i16)>>,
pub before_snapshot: Mutex<Option<Vec<(usize, i16)>>>,
}
impl Operation for FilteredOperation {
@ -130,7 +130,7 @@ impl Operation for FilteredOperation {
}
fn apply(&self, dim: &mut Dimension) -> Result<(), OperationError> {
let is_first_apply = self.before_snapshot.get().is_none();
let is_first_apply = self.before_snapshot.lock().unwrap().is_none();
let snapshot: Vec<(usize, i16)> = if is_first_apply {
let tile =
@ -159,10 +159,14 @@ impl Operation for FilteredOperation {
snap.push((idx, tile.heightmap[idx]));
}
}
let _ = self.before_snapshot.set(snap.clone());
self.before_snapshot.lock().unwrap().replace(snap.clone());
snap
} else {
self.before_snapshot.get().cloned().unwrap_or_default()
self.before_snapshot
.lock()
.unwrap()
.clone()
.unwrap_or_default()
};
self.operation.apply(dim)?;
@ -189,7 +193,12 @@ impl Operation for FilteredOperation {
}
fn inverse(&self) -> Box<dyn Operation> {
let snapshot = self.before_snapshot.get().cloned().unwrap_or_default();
let snapshot = self
.before_snapshot
.lock()
.unwrap()
.clone()
.unwrap_or_default();
Box::new(RestoreHeightsOperation {
tile_x: self.tile_x,
tile_z: self.tile_z,

View file

@ -3,7 +3,7 @@ use crate::model::tile::TILE_SIZE;
use crate::model::types::Brush;
use crate::model::types::Terrain;
use crate::ops::operations::{Operation, OperationError, RestoreTerrainOperation};
use std::sync::{Arc, OnceLock};
use std::sync::{Arc, Mutex};
struct BrushApply<'a> {
tile: &'a mut crate::model::tile::Tile,
@ -12,7 +12,7 @@ struct BrushApply<'a> {
radius: u32,
terrain_id: u8,
brush: &'a dyn Brush,
snapshot: &'a OnceLock<Vec<(usize, u8)>>,
snapshot: &'a Mutex<Option<Vec<(usize, u8)>>>,
snapshot_buf: &'a mut Vec<(usize, u8)>,
}
@ -25,11 +25,11 @@ fn apply_brush_terrain(args: BrushApply) {
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 {
if args.brush.get_strength(dx as f64, dz as f64) < 0.3 {
continue;
}
let idx = (az as usize) * TILE_SIZE + (ax as usize);
if args.snapshot.get().is_none() {
if args.snapshot.lock().unwrap().is_none() {
args.snapshot_buf.push((idx, args.tile.terrain[idx]));
}
args.tile.terrain[idx] = args.terrain_id;
@ -46,7 +46,7 @@ pub struct PaintOperation {
pub radius: u32,
pub terrain: Terrain,
pub brush: Arc<dyn Brush>,
pub before_snapshot: OnceLock<Vec<(usize, u8)>>,
pub before_snapshot: Mutex<Option<Vec<(usize, u8)>>>,
}
impl Operation for PaintOperation {
@ -77,66 +77,19 @@ impl Operation for PaintOperation {
snapshot_buf: &mut snapshot_buf,
});
let _ = self.before_snapshot.get_or_init(|| snapshot_buf);
if self.before_snapshot.lock().unwrap().is_none() {
self.before_snapshot.lock().unwrap().replace(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();
let snapshot = self
.before_snapshot
.lock()
.unwrap()
.clone()
.unwrap_or_default();
Box::new(RestoreTerrainOperation {
tile_x: self.tile_x,
tile_z: self.tile_z,
@ -152,7 +105,7 @@ pub struct PencilOperation {
pub center_x: u32,
pub center_z: u32,
pub terrain: Terrain,
pub before_snapshot: OnceLock<Vec<(usize, u8)>>,
pub before_snapshot: Mutex<Option<Vec<(usize, u8)>>>,
}
impl Operation for PencilOperation {
@ -183,8 +136,11 @@ impl Operation for PencilOperation {
}
let idx = cz * TILE_SIZE + cx;
if self.before_snapshot.get().is_none() {
let _ = self.before_snapshot.set(vec![(idx, tile.terrain[idx])]);
if self.before_snapshot.lock().unwrap().is_none() {
self.before_snapshot
.lock()
.unwrap()
.replace(vec![(idx, tile.terrain[idx])]);
}
tile.terrain[idx] = self.terrain as u8;
@ -192,7 +148,12 @@ impl Operation for PencilOperation {
}
fn inverse(&self) -> Box<dyn Operation> {
let snapshot = self.before_snapshot.get().cloned().unwrap_or_default();
let snapshot = self
.before_snapshot
.lock()
.unwrap()
.clone()
.unwrap_or_default();
Box::new(RestoreTerrainOperation {
tile_x: self.tile_x,
tile_z: self.tile_z,

View file

@ -1,5 +1,5 @@
use std::collections::VecDeque;
use std::sync::OnceLock;
use std::sync::Mutex;
use crate::model::dimension::Dimension;
use crate::model::tile::TILE_SIZE;
@ -14,7 +14,7 @@ pub struct FillOperation {
pub center_x: u32,
pub center_z: u32,
pub terrain: Terrain,
pub before_snapshot: OnceLock<Vec<(usize, u8)>>,
pub before_snapshot: Mutex<Option<Vec<(usize, u8)>>>,
}
impl Operation for FillOperation {
@ -60,7 +60,7 @@ impl Operation for FillOperation {
while let Some((x, z)) = queue.pop_front() {
let i = z * TILE_SIZE + x;
if self.before_snapshot.get().is_none() {
if self.before_snapshot.lock().unwrap().is_none() {
snapshot.push((i, tile.terrain[i]));
}
tile.terrain[i] = target_terrain;
@ -80,15 +80,20 @@ impl Operation for FillOperation {
}
}
if self.before_snapshot.get().is_none() {
let _ = self.before_snapshot.set(snapshot);
if self.before_snapshot.lock().unwrap().is_none() {
self.before_snapshot.lock().unwrap().replace(snapshot);
}
Ok(())
}
fn inverse(&self) -> Box<dyn Operation> {
let snapshot = self.before_snapshot.get().cloned().unwrap_or_default();
let snapshot = self
.before_snapshot
.lock()
.unwrap()
.clone()
.unwrap_or_default();
Box::new(RestoreTerrainOperation {
tile_x: self.tile_x,
tile_z: self.tile_z,

View file

@ -21,7 +21,7 @@ mod brush;
mod fill;
mod shaping;
pub use brush::{FloodOperation, PaintOperation, PencilOperation};
pub use brush::{PaintOperation, PencilOperation};
pub use fill::FillOperation;
pub use shaping::{
ErodeOperation, FlattenOperation, HeightOperation, MultiTileOperation, SmoothOperation,

View file

@ -1,5 +1,4 @@
use std::sync::Arc;
use std::sync::OnceLock;
use std::sync::{Arc, Mutex};
use crate::model::dimension::Dimension;
use crate::model::tile::TILE_SIZE;
@ -17,7 +16,7 @@ pub struct ErodeOperation {
pub iterations: u32,
pub talus_angle: f64,
pub brush: Arc<dyn Brush>,
pub before_snapshot: OnceLock<Vec<(usize, i16)>>,
pub before_snapshot: Mutex<Option<Vec<(usize, i16)>>>,
}
impl Operation for ErodeOperation {
@ -42,7 +41,7 @@ impl Operation for ErodeOperation {
// Snapshot original heights on first apply
let mut snapshot: Vec<(usize, i16)> = Vec::new();
let is_first_apply = self.before_snapshot.get().is_none();
let is_first_apply = self.before_snapshot.lock().unwrap().is_none();
if is_first_apply {
for dz in -r..=r {
for dx in -r..=r {
@ -60,7 +59,7 @@ impl Operation for ErodeOperation {
}
}
if is_first_apply {
let _ = self.before_snapshot.set(snapshot);
self.before_snapshot.lock().unwrap().replace(snapshot);
}
// Apply erosion for `iterations` passes
@ -142,7 +141,12 @@ impl Operation for ErodeOperation {
}
fn inverse(&self) -> Box<dyn Operation> {
let snapshot = self.before_snapshot.get().cloned().unwrap_or_default();
let snapshot = self
.before_snapshot
.lock()
.unwrap()
.clone()
.unwrap_or_default();
Box::new(RestoreHeightsOperation {
tile_x: self.tile_x,
tile_z: self.tile_z,

View file

@ -1,5 +1,4 @@
use std::sync::Arc;
use std::sync::OnceLock;
use std::sync::{Arc, Mutex};
use crate::model::dimension::Dimension;
use crate::model::tile::TILE_SIZE;
@ -16,7 +15,7 @@ pub struct HeightOperation {
pub radius: u32,
pub delta: i16,
pub brush: Arc<dyn Brush>,
pub before_snapshot: OnceLock<Vec<(usize, i16)>>,
pub before_snapshot: Mutex<Option<Vec<(usize, i16)>>>,
}
impl Operation for HeightOperation {
@ -55,7 +54,7 @@ impl Operation for HeightOperation {
let idx = (az as usize) * TILE_SIZE + (ax as usize);
if self.before_snapshot.get().is_none() {
if self.before_snapshot.lock().unwrap().is_none() {
heights.push((idx, tile.heightmap[idx]));
}
@ -66,15 +65,20 @@ impl Operation for HeightOperation {
}
}
if self.before_snapshot.get().is_none() {
let _ = self.before_snapshot.set(heights);
if self.before_snapshot.lock().unwrap().is_none() {
self.before_snapshot.lock().unwrap().replace(heights);
}
Ok(())
}
fn inverse(&self) -> Box<dyn Operation> {
let snapshot = self.before_snapshot.get().cloned().unwrap_or_default();
let snapshot = self
.before_snapshot
.lock()
.unwrap()
.clone()
.unwrap_or_default();
Box::new(RestoreHeightsOperation {
tile_x: self.tile_x,
tile_z: self.tile_z,
@ -92,7 +96,7 @@ pub struct FlattenOperation {
pub radius: u32,
pub target_height: i16,
pub brush: Arc<dyn Brush>,
pub before_snapshot: OnceLock<Vec<(usize, i16)>>,
pub before_snapshot: Mutex<Option<Vec<(usize, i16)>>>,
}
impl Operation for FlattenOperation {
@ -131,7 +135,7 @@ impl Operation for FlattenOperation {
let idx = (az as usize) * TILE_SIZE + (ax as usize);
if self.before_snapshot.get().is_none() {
if self.before_snapshot.lock().unwrap().is_none() {
heights.push((idx, tile.heightmap[idx]));
}
@ -145,15 +149,20 @@ impl Operation for FlattenOperation {
}
}
if self.before_snapshot.get().is_none() {
let _ = self.before_snapshot.set(heights);
if self.before_snapshot.lock().unwrap().is_none() {
self.before_snapshot.lock().unwrap().replace(heights);
}
Ok(())
}
fn inverse(&self) -> Box<dyn Operation> {
let snapshot = self.before_snapshot.get().cloned().unwrap_or_default();
let snapshot = self
.before_snapshot
.lock()
.unwrap()
.clone()
.unwrap_or_default();
Box::new(RestoreHeightsOperation {
tile_x: self.tile_x,
tile_z: self.tile_z,

View file

@ -1,5 +1,4 @@
use std::sync::Arc;
use std::sync::OnceLock;
use std::sync::{Arc, Mutex};
use crate::model::dimension::Dimension;
use crate::model::tile::TILE_SIZE;
@ -16,7 +15,7 @@ pub struct SmoothOperation {
pub radius: u32,
pub iterations: u32,
pub brush: Arc<dyn Brush>,
pub before_snapshot: OnceLock<Vec<(usize, i16)>>,
pub before_snapshot: Mutex<Option<Vec<(usize, i16)>>>,
}
impl Operation for SmoothOperation {
@ -51,7 +50,7 @@ impl Operation for SmoothOperation {
if self.brush.get_strength(dx as f64, dz as f64) <= 0.0 {
continue;
}
if self.before_snapshot.get().is_none() {
if self.before_snapshot.lock().unwrap().is_none() {
snapshot.push((
(az as usize) * TILE_SIZE + (ax as usize),
tile.heightmap[(az as usize) * TILE_SIZE + (ax as usize)],
@ -59,8 +58,8 @@ impl Operation for SmoothOperation {
}
}
}
if self.before_snapshot.get().is_none() {
let _ = self.before_snapshot.set(snapshot);
if self.before_snapshot.lock().unwrap().is_none() {
self.before_snapshot.lock().unwrap().replace(snapshot);
}
// Apply smoothing for `iterations` passes
@ -110,7 +109,12 @@ impl Operation for SmoothOperation {
}
fn inverse(&self) -> Box<dyn Operation> {
let snapshot = self.before_snapshot.get().cloned().unwrap_or_default();
let snapshot = self
.before_snapshot
.lock()
.unwrap()
.clone()
.unwrap_or_default();
Box::new(RestoreHeightsOperation {
tile_x: self.tile_x,
tile_z: self.tile_z,

View file

@ -1,6 +1,6 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::LazyLock;
use std::sync::atomic::{AtomicU8, Ordering};
mod lang;
pub use lang::Language;
@ -57,11 +57,6 @@ macro_rules! t {
($key:expr) => {
$crate::translate($key)
};
($key:expr, $($var:ident = $val:expr),+ $(,)?) => {{
let vars: &[(&str, &str)] = &[$((stringify!($var), &$val as &dyn std::fmt::Display as &str)),+];
// This won't work with runtime format. Use translate_with instead.
$crate::translate_with($key, &[$((stringify!($var), &$val.as_ref() as &str)),+])
}};
}
/// Detect system language from environment variables (LANG, LANGUAGE, LC_ALL).
@ -106,10 +101,7 @@ mod tests {
#[test]
fn test_translate_with_vars() {
let result = translate_with("cli.new.created", &[
("name", "test"),
("tiles", "9"),
]);
let result = translate_with("cli.new.created", &[("name", "test"), ("tiles", "9")]);
assert!(result.contains("test"));
assert!(result.contains("9"));
}

View file

@ -46,11 +46,7 @@ fn host_log(mut caller: Caller<'_, WorldContext>, level: i32, ptr: i32, len: i32
}
fn host_get_seed(caller: Caller<'_, WorldContext>) -> i64 {
let (lo, hi) = (
(caller.data().seed & 0xFFFF_FFFF) as i64,
((caller.data().seed >> 32) & 0xFFFF_FFFF) as i64,
);
lo | (hi << 32)
caller.data().seed as i64
}
fn host_tile_size(_caller: Caller<'_, WorldContext>) -> i32 {

View file

@ -71,8 +71,16 @@ impl eframe::App for TerrafierApp {
&& let Some(tile) = world.dimensions[0].tiles.values().next()
{
renderer.render(
&tile.heightmap.as_slice().try_into().unwrap_or_else(|_| [0; 16384]),
&tile.terrain.as_slice().try_into().unwrap_or_else(|_| [0; 16384]),
&tile
.heightmap
.as_slice()
.try_into()
.unwrap_or_else(|_| [0; 16384]),
&tile
.terrain
.as_slice()
.try_into()
.unwrap_or_else(|_| [0; 16384]),
self.show_heightmap,
);

View file

@ -1,5 +1,10 @@
use std::sync::Arc;
const TILE_SIZE: u32 = 128;
const TILE_CELLS: u32 = TILE_SIZE * TILE_SIZE;
const HEIGHTMAP_PACKED_SIZE: u64 = (TILE_CELLS / 2) as u64 * 4;
const TERRAIN_PACKED_SIZE: u64 = (TILE_CELLS / 4) as u64 * 4;
#[repr(C)]
#[derive(Clone, Copy)]
pub struct Uniforms {
@ -111,13 +116,13 @@ impl GpuRenderer {
});
let heightmap_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("heightmap_buffer"),
size: 8192 * 4,
size: HEIGHTMAP_PACKED_SIZE,
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,
size: TERRAIN_PACKED_SIZE,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});

View file

@ -105,7 +105,7 @@ pub fn render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
max_tz = max_tz.max(tz);
}
let base_size = 64u32;
let base_size = 32u32;
let zoom = app.view_zoom;
let grid_w = ((max_tx - min_tx + 1) * base_size as i32) as usize;
@ -190,10 +190,7 @@ pub fn render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
// Viewport: allocate a large area for the world with scroll
let viewport_size = Vec2::new(grid_w as f32 * zoom, grid_h as f32 * zoom);
let (response, painter) = ui.allocate_painter(
viewport_size,
egui::Sense::click_and_drag(),
);
let (response, painter) = ui.allocate_painter(viewport_size, egui::Sense::click_and_drag());
// Handle scroll-wheel zoom
let scroll_delta = ui.input(|i| i.raw_scroll_delta);
@ -215,7 +212,10 @@ pub fn render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
texture_id,
egui::Rect::from_min_max(
egui::pos2(rect.min.x + offset.x, rect.min.y + offset.y),
egui::pos2(rect.min.x + offset.x + grid_w as f32 * app.view_zoom, rect.min.y + offset.y + grid_h as f32 * app.view_zoom),
egui::pos2(
rect.min.x + offset.x + grid_w as f32 * app.view_zoom,
rect.min.y + offset.y + grid_h as f32 * app.view_zoom,
),
),
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
Color32::WHITE,
@ -229,8 +229,7 @@ pub fn render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
{
let bx = rect.min.x + offset.x + (sel_tx - min_tx) as f32 * scaled_size;
let bz = rect.min.y + offset.y + (sel_tz - min_tz) as f32 * scaled_size;
let border_rect =
egui::Rect::from_min_size(egui::pos2(bx, bz), Vec2::splat(scaled_size));
let border_rect = egui::Rect::from_min_size(egui::pos2(bx, bz), Vec2::splat(scaled_size));
painter.rect_stroke(
border_rect,
0.0,
@ -267,10 +266,12 @@ pub fn render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
let tz = (lz / scaled_size).floor() as i32 + min_tz;
if dim.tiles.contains_key(&(tx, tz)) {
app.selected_tile = Some((tx, tz));
let local_x =
((lx as u32 % scaled_size as u32) * TILE_SIZE as u32 / scaled_size as u32).min(127);
let local_z =
((lz as u32 % scaled_size as u32) * TILE_SIZE as u32 / scaled_size as u32).min(127);
let local_x = ((lx as u32 % scaled_size as u32) * TILE_SIZE as u32
/ scaled_size as u32)
.min(127);
let local_z = ((lz as u32 % scaled_size as u32) * TILE_SIZE as u32
/ scaled_size as u32)
.min(127);
app.brush_local_x = Some(local_x);
app.brush_local_z = Some(local_z);
app.status_message = format!(

View file

@ -5,16 +5,20 @@ pub fn try_render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) -> bool
let Some(ref world) = app.world else {
return false;
};
let dim = &world.dimensions[0];
let Some(dim) = world.dimensions.first() else {
return false;
};
// If world has more than one tile, fall back to CPU renderer
if dim.tiles.len() > 1 {
return false;
}
if let Some(ref renderer) = app.renderer
&& let Some(texture_id) = renderer.texture_id
{
let desired_size = Vec2::new(512.0, 512.0);
let (response, painter) = ui.allocate_painter(
desired_size,
egui::Sense::click_and_drag(),
);
let (response, painter) = ui.allocate_painter(desired_size, egui::Sense::click_and_drag());
let rect = response.rect;
// Handle scroll-wheel zoom
@ -50,8 +54,10 @@ pub fn try_render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) -> bool
// GPU only renders the first tile - position it correctly
// Use a default position for the rendered tile
let first_tile_pos = dim.tiles.keys().next().copied().unwrap_or((0, 0));
let tex_origin_x = rect.min.x + offset.x + (first_tile_pos.0 - min_tx) as f32 * display_size;
let tex_origin_y = rect.min.y + offset.y + (first_tile_pos.1 - min_tz) as f32 * display_size;
let tex_origin_x =
rect.min.x + offset.x + (first_tile_pos.0 - min_tx) as f32 * display_size;
let tex_origin_y =
rect.min.y + offset.y + (first_tile_pos.1 - min_tz) as f32 * display_size;
let tex_rect = egui::Rect::from_min_size(
egui::pos2(tex_origin_x, tex_origin_y),
@ -82,10 +88,14 @@ pub fn try_render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) -> bool
{
let tile_x_in_pixels = (sel_tx - min_tx) as f32 * display_size;
let tile_z_in_pixels = (sel_tz - min_tz) as f32 * display_size;
let brush_x =
rect.min.x + offset.x + tile_x_in_pixels + (bx as f32 * display_size / TILE_SIZE as f32);
let brush_z =
rect.min.y + offset.y + tile_z_in_pixels + (bz as f32 * display_size / TILE_SIZE as f32);
let brush_x = rect.min.x
+ offset.x
+ tile_x_in_pixels
+ (bx as f32 * display_size / TILE_SIZE as f32);
let brush_z = rect.min.y
+ offset.y
+ tile_z_in_pixels
+ (bz as f32 * display_size / TILE_SIZE as f32);
let brush_radius_px = app.brush_radius as f32 * display_size / TILE_SIZE as f32;
painter.circle_stroke(
egui::pos2(brush_x, brush_z),