mirror of
https://github.com/louis-e/arnis.git
synced 2025-12-23 22:37:56 -05:00
Refactor world_editor into modular directory structure
This commit is contained in:
2150
src/world_editor.rs
2150
src/world_editor.rs
File diff suppressed because it is too large
Load Diff
1028
src/world_editor/bedrock.rs
Normal file
1028
src/world_editor/bedrock.rs
Normal file
File diff suppressed because it is too large
Load Diff
312
src/world_editor/common.rs
Normal file
312
src/world_editor/common.rs
Normal file
@@ -0,0 +1,312 @@
|
||||
//! Common data structures for world modification.
|
||||
//!
|
||||
//! This module contains the internal data structures used to track block changes
|
||||
//! before they are written to either Java or Bedrock format.
|
||||
|
||||
use crate::block_definitions::*;
|
||||
use fastnbt::{LongArray, Value};
|
||||
use fnv::FnvHashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Chunk structure for Java Edition NBT format
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Chunk {
|
||||
pub sections: Vec<Section>,
|
||||
pub x_pos: i32,
|
||||
pub z_pos: i32,
|
||||
#[serde(default)]
|
||||
pub is_light_on: u8,
|
||||
#[serde(flatten)]
|
||||
pub other: FnvHashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// Section within a chunk (16x16x16 blocks)
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub(crate) struct Section {
|
||||
pub block_states: Blockstates,
|
||||
#[serde(rename = "Y")]
|
||||
pub y: i8,
|
||||
#[serde(flatten)]
|
||||
pub other: FnvHashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// Block states within a section
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub(crate) struct Blockstates {
|
||||
pub palette: Vec<PaletteItem>,
|
||||
pub data: Option<LongArray>,
|
||||
#[serde(flatten)]
|
||||
pub other: FnvHashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// Palette item for block state encoding
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub(crate) struct PaletteItem {
|
||||
#[serde(rename = "Name")]
|
||||
pub name: String,
|
||||
#[serde(rename = "Properties")]
|
||||
pub properties: Option<Value>,
|
||||
}
|
||||
|
||||
/// A section being modified (16x16x16 blocks)
|
||||
pub(crate) struct SectionToModify {
|
||||
pub blocks: [Block; 4096],
|
||||
/// Store properties for blocks that have them, indexed by the same index as blocks array
|
||||
pub properties: FnvHashMap<usize, Value>,
|
||||
}
|
||||
|
||||
impl SectionToModify {
|
||||
#[inline]
|
||||
pub fn get_block(&self, x: u8, y: u8, z: u8) -> Option<Block> {
|
||||
let b = self.blocks[Self::index(x, y, z)];
|
||||
if b == AIR {
|
||||
return None;
|
||||
}
|
||||
Some(b)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn set_block(&mut self, x: u8, y: u8, z: u8, block: Block) {
|
||||
self.blocks[Self::index(x, y, z)] = block;
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn set_block_with_properties(
|
||||
&mut self,
|
||||
x: u8,
|
||||
y: u8,
|
||||
z: u8,
|
||||
block_with_props: BlockWithProperties,
|
||||
) {
|
||||
let index = Self::index(x, y, z);
|
||||
self.blocks[index] = block_with_props.block;
|
||||
|
||||
// Store properties if they exist
|
||||
if let Some(props) = block_with_props.properties {
|
||||
self.properties.insert(index, props);
|
||||
} else {
|
||||
// Remove any existing properties for this position
|
||||
self.properties.remove(&index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate index from coordinates (YZX order)
|
||||
#[inline(always)]
|
||||
pub fn index(x: u8, y: u8, z: u8) -> usize {
|
||||
usize::from(y) % 16 * 256 + usize::from(z) * 16 + usize::from(x)
|
||||
}
|
||||
|
||||
/// Convert to Java Edition section format
|
||||
pub fn to_section(&self, y: i8) -> Section {
|
||||
// Create a map of unique block+properties combinations to palette indices
|
||||
let mut unique_blocks: Vec<(Block, Option<Value>)> = Vec::new();
|
||||
let mut palette_lookup: FnvHashMap<(Block, Option<String>), usize> = FnvHashMap::default();
|
||||
|
||||
// Build unique block combinations and lookup table
|
||||
for (i, &block) in self.blocks.iter().enumerate() {
|
||||
let properties = self.properties.get(&i).cloned();
|
||||
|
||||
// Create a key for the lookup (block + properties hash)
|
||||
let props_key = properties.as_ref().map(|p| format!("{p:?}"));
|
||||
let lookup_key = (block, props_key);
|
||||
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = palette_lookup.entry(lookup_key) {
|
||||
let palette_index = unique_blocks.len();
|
||||
e.insert(palette_index);
|
||||
unique_blocks.push((block, properties));
|
||||
}
|
||||
}
|
||||
|
||||
let mut bits_per_block = 4; // minimum allowed
|
||||
while (1 << bits_per_block) < unique_blocks.len() {
|
||||
bits_per_block += 1;
|
||||
}
|
||||
|
||||
let mut data = vec![];
|
||||
let mut cur = 0;
|
||||
let mut cur_idx = 0;
|
||||
|
||||
for (i, &block) in self.blocks.iter().enumerate() {
|
||||
let properties = self.properties.get(&i).cloned();
|
||||
let props_key = properties.as_ref().map(|p| format!("{p:?}"));
|
||||
let lookup_key = (block, props_key);
|
||||
let p = palette_lookup[&lookup_key] as i64;
|
||||
|
||||
if cur_idx + bits_per_block > 64 {
|
||||
data.push(cur);
|
||||
cur = 0;
|
||||
cur_idx = 0;
|
||||
}
|
||||
|
||||
cur |= p << cur_idx;
|
||||
cur_idx += bits_per_block;
|
||||
}
|
||||
|
||||
if cur_idx > 0 {
|
||||
data.push(cur);
|
||||
}
|
||||
|
||||
let palette = unique_blocks
|
||||
.iter()
|
||||
.map(|(block, stored_props)| PaletteItem {
|
||||
name: block.name().to_string(),
|
||||
properties: stored_props.clone().or_else(|| block.properties()),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Section {
|
||||
block_states: Blockstates {
|
||||
palette,
|
||||
data: Some(LongArray::new(data)),
|
||||
other: FnvHashMap::default(),
|
||||
},
|
||||
y,
|
||||
other: FnvHashMap::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SectionToModify {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
blocks: [AIR; 4096],
|
||||
properties: FnvHashMap::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A chunk being modified (16x384x16 blocks, divided into sections)
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ChunkToModify {
|
||||
pub sections: FnvHashMap<i8, SectionToModify>,
|
||||
pub other: FnvHashMap<String, Value>,
|
||||
}
|
||||
|
||||
impl ChunkToModify {
|
||||
#[inline]
|
||||
pub fn get_block(&self, x: u8, y: i32, z: u8) -> Option<Block> {
|
||||
let section_idx: i8 = (y >> 4).try_into().unwrap();
|
||||
let section = self.sections.get(§ion_idx)?;
|
||||
section.get_block(x, (y & 15).try_into().unwrap(), z)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn set_block(&mut self, x: u8, y: i32, z: u8, block: Block) {
|
||||
let section_idx: i8 = (y >> 4).try_into().unwrap();
|
||||
let section = self.sections.entry(section_idx).or_default();
|
||||
section.set_block(x, (y & 15).try_into().unwrap(), z, block);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn set_block_with_properties(
|
||||
&mut self,
|
||||
x: u8,
|
||||
y: i32,
|
||||
z: u8,
|
||||
block_with_props: BlockWithProperties,
|
||||
) {
|
||||
let section_idx: i8 = (y >> 4).try_into().unwrap();
|
||||
let section = self.sections.entry(section_idx).or_default();
|
||||
section.set_block_with_properties(x, (y & 15).try_into().unwrap(), z, block_with_props);
|
||||
}
|
||||
|
||||
pub fn sections(&self) -> impl Iterator<Item = Section> + '_ {
|
||||
self.sections.iter().map(|(y, s)| s.to_section(*y))
|
||||
}
|
||||
}
|
||||
|
||||
/// A region being modified (32x32 chunks)
|
||||
#[derive(Default)]
|
||||
pub(crate) struct RegionToModify {
|
||||
pub chunks: FnvHashMap<(i32, i32), ChunkToModify>,
|
||||
}
|
||||
|
||||
impl RegionToModify {
|
||||
#[inline]
|
||||
pub fn get_or_create_chunk(&mut self, x: i32, z: i32) -> &mut ChunkToModify {
|
||||
self.chunks.entry((x, z)).or_default()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn get_chunk(&self, x: i32, z: i32) -> Option<&ChunkToModify> {
|
||||
self.chunks.get(&(x, z))
|
||||
}
|
||||
}
|
||||
|
||||
/// The entire world being modified
|
||||
#[derive(Default)]
|
||||
pub(crate) struct WorldToModify {
|
||||
pub regions: FnvHashMap<(i32, i32), RegionToModify>,
|
||||
}
|
||||
|
||||
impl WorldToModify {
|
||||
#[inline]
|
||||
pub fn get_or_create_region(&mut self, x: i32, z: i32) -> &mut RegionToModify {
|
||||
self.regions.entry((x, z)).or_default()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn get_region(&self, x: i32, z: i32) -> Option<&RegionToModify> {
|
||||
self.regions.get(&(x, z))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn get_block(&self, x: i32, y: i32, z: i32) -> Option<Block> {
|
||||
let chunk_x: i32 = x >> 4;
|
||||
let chunk_z: i32 = z >> 4;
|
||||
let region_x: i32 = chunk_x >> 5;
|
||||
let region_z: i32 = chunk_z >> 5;
|
||||
|
||||
let region: &RegionToModify = self.get_region(region_x, region_z)?;
|
||||
let chunk: &ChunkToModify = region.get_chunk(chunk_x & 31, chunk_z & 31)?;
|
||||
|
||||
chunk.get_block(
|
||||
(x & 15).try_into().unwrap(),
|
||||
y,
|
||||
(z & 15).try_into().unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn set_block(&mut self, x: i32, y: i32, z: i32, block: Block) {
|
||||
let chunk_x: i32 = x >> 4;
|
||||
let chunk_z: i32 = z >> 4;
|
||||
let region_x: i32 = chunk_x >> 5;
|
||||
let region_z: i32 = chunk_z >> 5;
|
||||
|
||||
let region: &mut RegionToModify = self.get_or_create_region(region_x, region_z);
|
||||
let chunk: &mut ChunkToModify = region.get_or_create_chunk(chunk_x & 31, chunk_z & 31);
|
||||
|
||||
chunk.set_block(
|
||||
(x & 15).try_into().unwrap(),
|
||||
y,
|
||||
(z & 15).try_into().unwrap(),
|
||||
block,
|
||||
);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn set_block_with_properties(
|
||||
&mut self,
|
||||
x: i32,
|
||||
y: i32,
|
||||
z: i32,
|
||||
block_with_props: BlockWithProperties,
|
||||
) {
|
||||
let chunk_x: i32 = x >> 4;
|
||||
let chunk_z: i32 = z >> 4;
|
||||
let region_x: i32 = chunk_x >> 5;
|
||||
let region_z: i32 = chunk_z >> 5;
|
||||
|
||||
let region: &mut RegionToModify = self.get_or_create_region(region_x, region_z);
|
||||
let chunk: &mut ChunkToModify = region.get_or_create_chunk(chunk_x & 31, chunk_z & 31);
|
||||
|
||||
chunk.set_block_with_properties(
|
||||
(x & 15).try_into().unwrap(),
|
||||
y,
|
||||
(z & 15).try_into().unwrap(),
|
||||
block_with_props,
|
||||
);
|
||||
}
|
||||
}
|
||||
323
src/world_editor/java.rs
Normal file
323
src/world_editor/java.rs
Normal file
@@ -0,0 +1,323 @@
|
||||
//! Java Edition Anvil format world saving.
|
||||
//!
|
||||
//! This module handles saving worlds in the Java Edition Anvil (.mca) format.
|
||||
|
||||
use super::common::{Chunk, ChunkToModify, Section};
|
||||
use super::WorldEditor;
|
||||
use crate::block_definitions::GRASS_BLOCK;
|
||||
use crate::progress::emit_gui_progress_update;
|
||||
use colored::Colorize;
|
||||
use fastanvil::Region;
|
||||
use fastnbt::Value;
|
||||
use fnv::FnvHashMap;
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use rayon::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use crate::telemetry::{send_log, LogLevel};
|
||||
|
||||
impl<'a> WorldEditor<'a> {
|
||||
/// Creates a region file for the given region coordinates.
|
||||
pub(super) fn create_region(&self, region_x: i32, region_z: i32) -> Region<File> {
|
||||
let out_path = self
|
||||
.world_dir
|
||||
.join(format!("region/r.{}.{}.mca", region_x, region_z));
|
||||
|
||||
const REGION_TEMPLATE: &[u8] = include_bytes!("../../assets/minecraft/region.template");
|
||||
|
||||
let mut region_file: File = File::options()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(&out_path)
|
||||
.expect("Failed to open region file");
|
||||
|
||||
region_file
|
||||
.write_all(REGION_TEMPLATE)
|
||||
.expect("Could not write region template");
|
||||
|
||||
Region::from_stream(region_file).expect("Failed to load region")
|
||||
}
|
||||
|
||||
/// Helper function to create a base chunk with grass blocks at Y -62
|
||||
pub(super) fn create_base_chunk(abs_chunk_x: i32, abs_chunk_z: i32) -> (Vec<u8>, bool) {
|
||||
let mut chunk = ChunkToModify::default();
|
||||
|
||||
// Fill the bottom layer with grass blocks at Y -62
|
||||
for x in 0..16 {
|
||||
for z in 0..16 {
|
||||
chunk.set_block(x, -62, z, GRASS_BLOCK);
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare chunk data
|
||||
let chunk_data = Chunk {
|
||||
sections: chunk.sections().collect(),
|
||||
x_pos: abs_chunk_x,
|
||||
z_pos: abs_chunk_z,
|
||||
is_light_on: 0,
|
||||
other: chunk.other,
|
||||
};
|
||||
|
||||
// Create the Level wrapper
|
||||
let level_data = create_level_wrapper(&chunk_data);
|
||||
|
||||
// Serialize the chunk with Level wrapper
|
||||
let mut ser_buffer = Vec::with_capacity(8192);
|
||||
fastnbt::to_writer(&mut ser_buffer, &level_data).unwrap();
|
||||
|
||||
(ser_buffer, true)
|
||||
}
|
||||
|
||||
/// Saves the world in Java Edition Anvil format.
|
||||
pub(super) fn save_java(&mut self) {
|
||||
println!("{} Saving world...", "[7/7]".bold());
|
||||
emit_gui_progress_update(90.0, "Saving world...");
|
||||
|
||||
// Save metadata with error handling
|
||||
if let Err(e) = self.save_metadata() {
|
||||
eprintln!("Failed to save world metadata: {}", e);
|
||||
#[cfg(feature = "gui")]
|
||||
send_log(LogLevel::Warning, "Failed to save world metadata.");
|
||||
// Continue with world saving even if metadata fails
|
||||
}
|
||||
|
||||
let total_regions = self.world.regions.len() as u64;
|
||||
let save_pb = ProgressBar::new(total_regions);
|
||||
save_pb.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template(
|
||||
"{spinner:.green} [{elapsed_precise}] [{bar:45}] {pos}/{len} regions ({eta})",
|
||||
)
|
||||
.unwrap()
|
||||
.progress_chars("█▓░"),
|
||||
);
|
||||
|
||||
let total_steps: f64 = 9.0;
|
||||
let progress_increment_save: f64 = total_steps / total_regions as f64;
|
||||
let current_progress = AtomicU64::new(900);
|
||||
let regions_processed = AtomicU64::new(0);
|
||||
|
||||
self.world
|
||||
.regions
|
||||
.par_iter()
|
||||
.for_each(|((region_x, region_z), region_to_modify)| {
|
||||
let mut region = self.create_region(*region_x, *region_z);
|
||||
let mut ser_buffer = Vec::with_capacity(8192);
|
||||
|
||||
for (&(chunk_x, chunk_z), chunk_to_modify) in ®ion_to_modify.chunks {
|
||||
if !chunk_to_modify.sections.is_empty() || !chunk_to_modify.other.is_empty() {
|
||||
// Read existing chunk data if it exists
|
||||
let existing_data = region
|
||||
.read_chunk(chunk_x as usize, chunk_z as usize)
|
||||
.unwrap()
|
||||
.unwrap_or_default();
|
||||
|
||||
// Parse existing chunk or create new one
|
||||
let mut chunk: Chunk = if !existing_data.is_empty() {
|
||||
fastnbt::from_bytes(&existing_data).unwrap()
|
||||
} else {
|
||||
Chunk {
|
||||
sections: Vec::new(),
|
||||
x_pos: chunk_x + (region_x * 32),
|
||||
z_pos: chunk_z + (region_z * 32),
|
||||
is_light_on: 0,
|
||||
other: FnvHashMap::default(),
|
||||
}
|
||||
};
|
||||
|
||||
// Update sections while preserving existing data
|
||||
let new_sections: Vec<Section> = chunk_to_modify.sections().collect();
|
||||
for new_section in new_sections {
|
||||
if let Some(existing_section) =
|
||||
chunk.sections.iter_mut().find(|s| s.y == new_section.y)
|
||||
{
|
||||
// Merge block states
|
||||
existing_section.block_states.palette =
|
||||
new_section.block_states.palette;
|
||||
existing_section.block_states.data = new_section.block_states.data;
|
||||
} else {
|
||||
// Add new section if it doesn't exist
|
||||
chunk.sections.push(new_section);
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve existing block entities and merge with new ones
|
||||
if let Some(existing_entities) = chunk.other.get_mut("block_entities") {
|
||||
if let Some(new_entities) = chunk_to_modify.other.get("block_entities")
|
||||
{
|
||||
if let (Value::List(existing), Value::List(new)) =
|
||||
(existing_entities, new_entities)
|
||||
{
|
||||
// Remove old entities that are replaced by new ones
|
||||
existing.retain(|e| {
|
||||
if let Value::Compound(map) = e {
|
||||
let (x, y, z) = get_entity_coords(map);
|
||||
!new.iter().any(|new_e| {
|
||||
if let Value::Compound(new_map) = new_e {
|
||||
let (nx, ny, nz) = get_entity_coords(new_map);
|
||||
x == nx && y == ny && z == nz
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
// Add new entities
|
||||
existing.extend(new.clone());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If no existing entities, just add the new ones
|
||||
if let Some(new_entities) = chunk_to_modify.other.get("block_entities")
|
||||
{
|
||||
chunk
|
||||
.other
|
||||
.insert("block_entities".to_string(), new_entities.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Update chunk coordinates and flags
|
||||
chunk.x_pos = chunk_x + (region_x * 32);
|
||||
chunk.z_pos = chunk_z + (region_z * 32);
|
||||
|
||||
// Create Level wrapper and save
|
||||
let level_data = create_level_wrapper(&chunk);
|
||||
ser_buffer.clear();
|
||||
fastnbt::to_writer(&mut ser_buffer, &level_data).unwrap();
|
||||
region
|
||||
.write_chunk(chunk_x as usize, chunk_z as usize, &ser_buffer)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: ensure all chunks exist
|
||||
for chunk_x in 0..32 {
|
||||
for chunk_z in 0..32 {
|
||||
let abs_chunk_x = chunk_x + (region_x * 32);
|
||||
let abs_chunk_z = chunk_z + (region_z * 32);
|
||||
|
||||
// Check if chunk exists in our modifications
|
||||
let chunk_exists =
|
||||
region_to_modify.chunks.contains_key(&(chunk_x, chunk_z));
|
||||
|
||||
// If chunk doesn't exist, create it with base layer
|
||||
if !chunk_exists {
|
||||
let (ser_buffer, _) = Self::create_base_chunk(abs_chunk_x, abs_chunk_z);
|
||||
region
|
||||
.write_chunk(chunk_x as usize, chunk_z as usize, &ser_buffer)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update progress
|
||||
let regions_done = regions_processed.fetch_add(1, Ordering::SeqCst);
|
||||
let new_progress = (90.0 + (regions_done as f64 * progress_increment_save)) * 10.0;
|
||||
let prev_progress =
|
||||
current_progress.fetch_max(new_progress as u64, Ordering::SeqCst);
|
||||
|
||||
if new_progress as u64 - prev_progress > 1 {
|
||||
emit_gui_progress_update(new_progress / 10.0, "Saving world...");
|
||||
}
|
||||
|
||||
save_pb.inc(1);
|
||||
});
|
||||
|
||||
save_pb.finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to get entity coordinates
|
||||
#[inline]
|
||||
fn get_entity_coords(entity: &HashMap<String, Value>) -> (i32, i32, i32) {
|
||||
let x = if let Value::Int(x) = entity.get("x").unwrap_or(&Value::Int(0)) {
|
||||
*x
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let y = if let Value::Int(y) = entity.get("y").unwrap_or(&Value::Int(0)) {
|
||||
*y
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let z = if let Value::Int(z) = entity.get("z").unwrap_or(&Value::Int(0)) {
|
||||
*z
|
||||
} else {
|
||||
0
|
||||
};
|
||||
(x, y, z)
|
||||
}
|
||||
|
||||
/// Creates a Level wrapper for chunk data (Java Edition format)
|
||||
#[inline]
|
||||
fn create_level_wrapper(chunk: &Chunk) -> HashMap<String, Value> {
|
||||
HashMap::from([(
|
||||
"Level".to_string(),
|
||||
Value::Compound(HashMap::from([
|
||||
("xPos".to_string(), Value::Int(chunk.x_pos)),
|
||||
("zPos".to_string(), Value::Int(chunk.z_pos)),
|
||||
(
|
||||
"isLightOn".to_string(),
|
||||
Value::Byte(i8::try_from(chunk.is_light_on).unwrap()),
|
||||
),
|
||||
(
|
||||
"sections".to_string(),
|
||||
Value::List(
|
||||
chunk
|
||||
.sections
|
||||
.iter()
|
||||
.map(|section| {
|
||||
let mut block_states = HashMap::from([(
|
||||
"palette".to_string(),
|
||||
Value::List(
|
||||
section
|
||||
.block_states
|
||||
.palette
|
||||
.iter()
|
||||
.map(|item| {
|
||||
let mut palette_item = HashMap::from([(
|
||||
"Name".to_string(),
|
||||
Value::String(item.name.clone()),
|
||||
)]);
|
||||
if let Some(props) = &item.properties {
|
||||
palette_item.insert(
|
||||
"Properties".to_string(),
|
||||
props.clone(),
|
||||
);
|
||||
}
|
||||
Value::Compound(palette_item)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
)]);
|
||||
|
||||
// only add the `data` attribute if it's non-empty
|
||||
// some software (cough cough dynmap) chokes otherwise
|
||||
if let Some(data) = §ion.block_states.data {
|
||||
if !data.is_empty() {
|
||||
block_states.insert(
|
||||
"data".to_string(),
|
||||
Value::LongArray(data.to_owned()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Value::Compound(HashMap::from([
|
||||
("Y".to_string(), Value::Byte(section.y)),
|
||||
("block_states".to_string(), Value::Compound(block_states)),
|
||||
]))
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
),
|
||||
])),
|
||||
)])
|
||||
}
|
||||
578
src/world_editor/mod.rs
Normal file
578
src/world_editor/mod.rs
Normal file
@@ -0,0 +1,578 @@
|
||||
//! World editor module for generating Minecraft worlds.
|
||||
//!
|
||||
//! This module provides the `WorldEditor` struct which handles block placement
|
||||
//! and world saving in both Java Edition (Anvil) and Bedrock Edition (.mcworld) formats.
|
||||
//!
|
||||
//! # Module Structure
|
||||
//!
|
||||
//! - `common` - Shared data structures for world modification
|
||||
//! - `java` - Java Edition Anvil format saving
|
||||
//! - `bedrock` - Bedrock Edition .mcworld format saving (behind `bedrock` feature)
|
||||
|
||||
mod common;
|
||||
mod java;
|
||||
|
||||
#[cfg(feature = "bedrock")]
|
||||
pub mod bedrock;
|
||||
|
||||
// Re-export common types used internally
|
||||
pub(crate) use common::WorldToModify;
|
||||
|
||||
#[cfg(feature = "bedrock")]
|
||||
pub(crate) use bedrock::{BedrockSaveError, BedrockWriter};
|
||||
|
||||
use crate::block_definitions::*;
|
||||
use crate::coordinate_system::cartesian::{XZBBox, XZPoint};
|
||||
use crate::coordinate_system::geographic::LLBBox;
|
||||
use crate::ground::Ground;
|
||||
use crate::progress::emit_gui_progress_update;
|
||||
use colored::Colorize;
|
||||
use fastnbt::Value;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use crate::telemetry::{send_log, LogLevel};
|
||||
|
||||
/// World format to generate
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[allow(dead_code)]
|
||||
pub enum WorldFormat {
|
||||
/// Java Edition Anvil format (.mca region files)
|
||||
JavaAnvil,
|
||||
/// Bedrock Edition .mcworld format
|
||||
BedrockMcWorld,
|
||||
}
|
||||
|
||||
/// Metadata saved with the world
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct WorldMetadata {
|
||||
pub min_mc_x: i32,
|
||||
pub max_mc_x: i32,
|
||||
pub min_mc_z: i32,
|
||||
pub max_mc_z: i32,
|
||||
|
||||
pub min_geo_lat: f64,
|
||||
pub max_geo_lat: f64,
|
||||
pub min_geo_lon: f64,
|
||||
pub max_geo_lon: f64,
|
||||
}
|
||||
|
||||
/// The main world editor struct for placing blocks and saving worlds.
|
||||
///
|
||||
/// # Lifetime Parameter
|
||||
///
|
||||
/// The lifetime `'a` is tied to the `XZBBox` reference, which defines the
|
||||
/// world boundaries. This is similar to a C++ template:
|
||||
/// ```cpp
|
||||
/// template<lifetime A>
|
||||
/// struct WorldEditor { const XZBBox<A>& xzbbox; }
|
||||
/// ```
|
||||
pub struct WorldEditor<'a> {
|
||||
world_dir: PathBuf,
|
||||
world: WorldToModify,
|
||||
xzbbox: &'a XZBBox,
|
||||
llbbox: LLBBox,
|
||||
ground: Option<Box<Ground>>,
|
||||
format: WorldFormat,
|
||||
/// Optional level name for Bedrock worlds (e.g., "Arnis World: New York City")
|
||||
bedrock_level_name: Option<String>,
|
||||
}
|
||||
|
||||
impl<'a> WorldEditor<'a> {
|
||||
/// Creates a new WorldEditor with Java Anvil format (default).
|
||||
///
|
||||
/// This is the default constructor used by CLI mode.
|
||||
pub fn new(world_dir: PathBuf, xzbbox: &'a XZBBox, llbbox: LLBBox) -> Self {
|
||||
Self {
|
||||
world_dir,
|
||||
world: WorldToModify::default(),
|
||||
xzbbox,
|
||||
llbbox,
|
||||
ground: None,
|
||||
format: WorldFormat::JavaAnvil,
|
||||
bedrock_level_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new WorldEditor with a specific format and optional level name.
|
||||
///
|
||||
/// Used by GUI mode to support both Java and Bedrock formats.
|
||||
#[allow(dead_code)]
|
||||
pub fn new_with_format_and_name(
|
||||
world_dir: PathBuf,
|
||||
xzbbox: &'a XZBBox,
|
||||
llbbox: LLBBox,
|
||||
format: WorldFormat,
|
||||
bedrock_level_name: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
world_dir,
|
||||
world: WorldToModify::default(),
|
||||
xzbbox,
|
||||
llbbox,
|
||||
ground: None,
|
||||
format,
|
||||
bedrock_level_name,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the ground reference for elevation-based block placement
|
||||
pub fn set_ground(&mut self, ground: &Ground) {
|
||||
self.ground = Some(Box::new(ground.clone()));
|
||||
}
|
||||
|
||||
/// Gets a reference to the ground data if available
|
||||
pub fn get_ground(&self) -> Option<&Ground> {
|
||||
self.ground.as_ref().map(|g| g.as_ref())
|
||||
}
|
||||
|
||||
/// Returns the current world format
|
||||
#[allow(dead_code)]
|
||||
pub fn format(&self) -> WorldFormat {
|
||||
self.format
|
||||
}
|
||||
|
||||
/// Calculate the absolute Y position from a ground-relative offset
|
||||
#[inline(always)]
|
||||
pub fn get_absolute_y(&self, x: i32, y_offset: i32, z: i32) -> i32 {
|
||||
if let Some(ground) = &self.ground {
|
||||
ground.level(XZPoint::new(
|
||||
x - self.xzbbox.min_x(),
|
||||
z - self.xzbbox.min_z(),
|
||||
)) + y_offset
|
||||
} else {
|
||||
y_offset // If no ground reference, use y_offset as absolute Y
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the minimum world coordinates
|
||||
pub fn get_min_coords(&self) -> (i32, i32) {
|
||||
(self.xzbbox.min_x(), self.xzbbox.min_z())
|
||||
}
|
||||
|
||||
/// Returns the maximum world coordinates
|
||||
pub fn get_max_coords(&self) -> (i32, i32) {
|
||||
(self.xzbbox.max_x(), self.xzbbox.max_z())
|
||||
}
|
||||
|
||||
/// Checks if there's a block at the given coordinates
|
||||
#[allow(unused)]
|
||||
#[inline]
|
||||
pub fn block_at(&self, x: i32, y: i32, z: i32) -> bool {
|
||||
let absolute_y = self.get_absolute_y(x, y, z);
|
||||
self.world.get_block(x, absolute_y, z).is_some()
|
||||
}
|
||||
|
||||
/// Sets a sign at the given coordinates
|
||||
#[allow(clippy::too_many_arguments, dead_code)]
|
||||
pub fn set_sign(
|
||||
&mut self,
|
||||
line1: String,
|
||||
line2: String,
|
||||
line3: String,
|
||||
line4: String,
|
||||
x: i32,
|
||||
y: i32,
|
||||
z: i32,
|
||||
_rotation: i8,
|
||||
) {
|
||||
let absolute_y = self.get_absolute_y(x, y, z);
|
||||
let chunk_x = x >> 4;
|
||||
let chunk_z = z >> 4;
|
||||
let region_x = chunk_x >> 5;
|
||||
let region_z = chunk_z >> 5;
|
||||
|
||||
let mut block_entities = HashMap::new();
|
||||
|
||||
let messages = vec![
|
||||
Value::String(format!("\"{line1}\"")),
|
||||
Value::String(format!("\"{line2}\"")),
|
||||
Value::String(format!("\"{line3}\"")),
|
||||
Value::String(format!("\"{line4}\"")),
|
||||
];
|
||||
|
||||
let mut text_data = HashMap::new();
|
||||
text_data.insert("messages".to_string(), Value::List(messages));
|
||||
text_data.insert("color".to_string(), Value::String("black".to_string()));
|
||||
text_data.insert("has_glowing_text".to_string(), Value::Byte(0));
|
||||
|
||||
block_entities.insert("front_text".to_string(), Value::Compound(text_data));
|
||||
block_entities.insert(
|
||||
"id".to_string(),
|
||||
Value::String("minecraft:sign".to_string()),
|
||||
);
|
||||
block_entities.insert("is_waxed".to_string(), Value::Byte(0));
|
||||
block_entities.insert("keepPacked".to_string(), Value::Byte(0));
|
||||
block_entities.insert("x".to_string(), Value::Int(x));
|
||||
block_entities.insert("y".to_string(), Value::Int(absolute_y));
|
||||
block_entities.insert("z".to_string(), Value::Int(z));
|
||||
|
||||
let region = self.world.get_or_create_region(region_x, region_z);
|
||||
let chunk = region.get_or_create_chunk(chunk_x & 31, chunk_z & 31);
|
||||
|
||||
if let Some(chunk_data) = chunk.other.get_mut("block_entities") {
|
||||
if let Value::List(entities) = chunk_data {
|
||||
entities.push(Value::Compound(block_entities));
|
||||
}
|
||||
} else {
|
||||
chunk.other.insert(
|
||||
"block_entities".to_string(),
|
||||
Value::List(vec![Value::Compound(block_entities)]),
|
||||
);
|
||||
}
|
||||
|
||||
self.set_block(SIGN, x, y, z, None, None);
|
||||
}
|
||||
|
||||
/// Sets a block of the specified type at the given coordinates.
|
||||
///
|
||||
/// Y value is interpreted as an offset from ground level.
|
||||
#[inline]
|
||||
pub fn set_block(
|
||||
&mut self,
|
||||
block: Block,
|
||||
x: i32,
|
||||
y: i32,
|
||||
z: i32,
|
||||
override_whitelist: Option<&[Block]>,
|
||||
override_blacklist: Option<&[Block]>,
|
||||
) {
|
||||
// Check if coordinates are within bounds
|
||||
if !self.xzbbox.contains(&XZPoint::new(x, z)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate the absolute Y coordinate based on ground level
|
||||
let absolute_y = self.get_absolute_y(x, y, z);
|
||||
|
||||
let should_insert = if let Some(existing_block) = self.world.get_block(x, absolute_y, z) {
|
||||
// Check against whitelist and blacklist
|
||||
if let Some(whitelist) = override_whitelist {
|
||||
whitelist
|
||||
.iter()
|
||||
.any(|whitelisted_block: &Block| whitelisted_block.id() == existing_block.id())
|
||||
} else if let Some(blacklist) = override_blacklist {
|
||||
!blacklist
|
||||
.iter()
|
||||
.any(|blacklisted_block: &Block| blacklisted_block.id() == existing_block.id())
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
true
|
||||
};
|
||||
|
||||
if should_insert {
|
||||
self.world.set_block(x, absolute_y, z, block);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets a block of the specified type at the given coordinates with absolute Y value.
|
||||
#[inline]
|
||||
pub fn set_block_absolute(
|
||||
&mut self,
|
||||
block: Block,
|
||||
x: i32,
|
||||
absolute_y: i32,
|
||||
z: i32,
|
||||
override_whitelist: Option<&[Block]>,
|
||||
override_blacklist: Option<&[Block]>,
|
||||
) {
|
||||
// Check if coordinates are within bounds
|
||||
if !self.xzbbox.contains(&XZPoint::new(x, z)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let should_insert = if let Some(existing_block) = self.world.get_block(x, absolute_y, z) {
|
||||
// Check against whitelist and blacklist
|
||||
if let Some(whitelist) = override_whitelist {
|
||||
whitelist
|
||||
.iter()
|
||||
.any(|whitelisted_block: &Block| whitelisted_block.id() == existing_block.id())
|
||||
} else if let Some(blacklist) = override_blacklist {
|
||||
!blacklist
|
||||
.iter()
|
||||
.any(|blacklisted_block: &Block| blacklisted_block.id() == existing_block.id())
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
true
|
||||
};
|
||||
|
||||
if should_insert {
|
||||
self.world.set_block(x, absolute_y, z, block);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets a block with properties at the given coordinates with absolute Y value.
|
||||
#[inline]
|
||||
pub fn set_block_with_properties_absolute(
|
||||
&mut self,
|
||||
block_with_props: BlockWithProperties,
|
||||
x: i32,
|
||||
absolute_y: i32,
|
||||
z: i32,
|
||||
override_whitelist: Option<&[Block]>,
|
||||
override_blacklist: Option<&[Block]>,
|
||||
) {
|
||||
// Check if coordinates are within bounds
|
||||
if !self.xzbbox.contains(&XZPoint::new(x, z)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let should_insert = if let Some(existing_block) = self.world.get_block(x, absolute_y, z) {
|
||||
// Check against whitelist and blacklist
|
||||
if let Some(whitelist) = override_whitelist {
|
||||
whitelist
|
||||
.iter()
|
||||
.any(|whitelisted_block: &Block| whitelisted_block.id() == existing_block.id())
|
||||
} else if let Some(blacklist) = override_blacklist {
|
||||
!blacklist
|
||||
.iter()
|
||||
.any(|blacklisted_block: &Block| blacklisted_block.id() == existing_block.id())
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
true
|
||||
};
|
||||
|
||||
if should_insert {
|
||||
self.world
|
||||
.set_block_with_properties(x, absolute_y, z, block_with_props);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fills a cuboid area with the specified block between two coordinates.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[inline]
|
||||
pub fn fill_blocks(
|
||||
&mut self,
|
||||
block: Block,
|
||||
x1: i32,
|
||||
y1: i32,
|
||||
z1: i32,
|
||||
x2: i32,
|
||||
y2: i32,
|
||||
z2: i32,
|
||||
override_whitelist: Option<&[Block]>,
|
||||
override_blacklist: Option<&[Block]>,
|
||||
) {
|
||||
let (min_x, max_x) = if x1 < x2 { (x1, x2) } else { (x2, x1) };
|
||||
let (min_y, max_y) = if y1 < y2 { (y1, y2) } else { (y2, y1) };
|
||||
let (min_z, max_z) = if z1 < z2 { (z1, z2) } else { (z2, z1) };
|
||||
|
||||
for x in min_x..=max_x {
|
||||
for y_offset in min_y..=max_y {
|
||||
for z in min_z..=max_z {
|
||||
self.set_block(
|
||||
block,
|
||||
x,
|
||||
y_offset,
|
||||
z,
|
||||
override_whitelist,
|
||||
override_blacklist,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fills a cuboid area with the specified block between two coordinates using absolute Y values.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[inline]
|
||||
pub fn fill_blocks_absolute(
|
||||
&mut self,
|
||||
block: Block,
|
||||
x1: i32,
|
||||
y1_absolute: i32,
|
||||
z1: i32,
|
||||
x2: i32,
|
||||
y2_absolute: i32,
|
||||
z2: i32,
|
||||
override_whitelist: Option<&[Block]>,
|
||||
override_blacklist: Option<&[Block]>,
|
||||
) {
|
||||
let (min_x, max_x) = if x1 < x2 { (x1, x2) } else { (x2, x1) };
|
||||
let (min_y, max_y) = if y1_absolute < y2_absolute {
|
||||
(y1_absolute, y2_absolute)
|
||||
} else {
|
||||
(y2_absolute, y1_absolute)
|
||||
};
|
||||
let (min_z, max_z) = if z1 < z2 { (z1, z2) } else { (z2, z1) };
|
||||
|
||||
for x in min_x..=max_x {
|
||||
for absolute_y in min_y..=max_y {
|
||||
for z in min_z..=max_z {
|
||||
self.set_block_absolute(
|
||||
block,
|
||||
x,
|
||||
absolute_y,
|
||||
z,
|
||||
override_whitelist,
|
||||
override_blacklist,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks for a block at the given coordinates.
|
||||
#[inline]
|
||||
pub fn check_for_block(&self, x: i32, y: i32, z: i32, whitelist: Option<&[Block]>) -> bool {
|
||||
let absolute_y = self.get_absolute_y(x, y, z);
|
||||
|
||||
// Retrieve the chunk modification map
|
||||
if let Some(existing_block) = self.world.get_block(x, absolute_y, z) {
|
||||
if let Some(whitelist) = whitelist {
|
||||
if whitelist
|
||||
.iter()
|
||||
.any(|whitelisted_block: &Block| whitelisted_block.id() == existing_block.id())
|
||||
{
|
||||
return true; // Block is in the list
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Checks for a block at the given coordinates with absolute Y value.
|
||||
#[allow(unused)]
|
||||
pub fn check_for_block_absolute(
|
||||
&self,
|
||||
x: i32,
|
||||
absolute_y: i32,
|
||||
z: i32,
|
||||
whitelist: Option<&[Block]>,
|
||||
blacklist: Option<&[Block]>,
|
||||
) -> bool {
|
||||
// Retrieve the chunk modification map
|
||||
if let Some(existing_block) = self.world.get_block(x, absolute_y, z) {
|
||||
// Check against whitelist and blacklist
|
||||
if let Some(whitelist) = whitelist {
|
||||
if whitelist
|
||||
.iter()
|
||||
.any(|whitelisted_block: &Block| whitelisted_block.id() == existing_block.id())
|
||||
{
|
||||
return true; // Block is in whitelist
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if let Some(blacklist) = blacklist {
|
||||
if blacklist
|
||||
.iter()
|
||||
.any(|blacklisted_block: &Block| blacklisted_block.id() == existing_block.id())
|
||||
{
|
||||
return true; // Block is in blacklist
|
||||
}
|
||||
}
|
||||
return whitelist.is_none() && blacklist.is_none();
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Checks if a block exists at the given coordinates with absolute Y value.
|
||||
///
|
||||
/// Unlike `check_for_block_absolute`, this doesn't filter by block type.
|
||||
#[allow(unused)]
|
||||
pub fn block_at_absolute(&self, x: i32, absolute_y: i32, z: i32) -> bool {
|
||||
self.world.get_block(x, absolute_y, z).is_some()
|
||||
}
|
||||
|
||||
/// Saves all changes made to the world by writing to the appropriate format.
|
||||
pub fn save(&mut self) {
|
||||
match self.format {
|
||||
WorldFormat::JavaAnvil => self.save_java(),
|
||||
WorldFormat::BedrockMcWorld => self.save_bedrock(),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unreachable_code)]
|
||||
fn save_bedrock(&mut self) {
|
||||
println!("{} Saving Bedrock world...", "[7/7]".bold());
|
||||
emit_gui_progress_update(90.0, "Saving Bedrock world...");
|
||||
|
||||
#[cfg(feature = "bedrock")]
|
||||
{
|
||||
if let Err(error) = self.save_bedrock_internal() {
|
||||
eprintln!("Failed to save Bedrock world: {error}");
|
||||
#[cfg(feature = "gui")]
|
||||
send_log(
|
||||
LogLevel::Error,
|
||||
&format!("Failed to save Bedrock world: {error}"),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "bedrock"))]
|
||||
{
|
||||
eprintln!(
|
||||
"Bedrock output requested but the 'bedrock' feature is not enabled at build time."
|
||||
);
|
||||
#[cfg(feature = "gui")]
|
||||
send_log(
|
||||
LogLevel::Error,
|
||||
"Bedrock output requested but the 'bedrock' feature is not enabled at build time.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "bedrock")]
|
||||
fn save_bedrock_internal(&mut self) -> Result<(), BedrockSaveError> {
|
||||
// Use the stored level name if available, otherwise extract from path
|
||||
let level_name = self.bedrock_level_name.clone().unwrap_or_else(|| {
|
||||
self.world_dir
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("Arnis World")
|
||||
.to_string()
|
||||
});
|
||||
|
||||
BedrockWriter::new(self.world_dir.clone(), level_name).write_world(
|
||||
&self.world,
|
||||
self.xzbbox,
|
||||
&self.llbbox,
|
||||
)
|
||||
}
|
||||
|
||||
/// Saves world metadata to a JSON file
|
||||
pub(crate) fn save_metadata(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let metadata_path = self.world_dir.join("metadata.json");
|
||||
|
||||
let mut file = File::create(&metadata_path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to create metadata file at {}: {}",
|
||||
metadata_path.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let metadata = WorldMetadata {
|
||||
min_mc_x: self.xzbbox.min_x(),
|
||||
max_mc_x: self.xzbbox.max_x(),
|
||||
min_mc_z: self.xzbbox.min_z(),
|
||||
max_mc_z: self.xzbbox.max_z(),
|
||||
|
||||
min_geo_lat: self.llbbox.min().lat(),
|
||||
max_geo_lat: self.llbbox.max().lat(),
|
||||
min_geo_lon: self.llbbox.min().lng(),
|
||||
max_geo_lon: self.llbbox.max().lng(),
|
||||
};
|
||||
|
||||
let contents = serde_json::to_string(&metadata)
|
||||
.map_err(|e| format!("Failed to serialize metadata to JSON: {}", e))?;
|
||||
|
||||
write!(&mut file, "{}", contents)
|
||||
.map_err(|e| format!("Failed to write metadata to file: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user