Compare commits

..

22 Commits

Author SHA1 Message Date
Louis Erbkamm
5e01abc5b6 Merge pull request #707 from louis-e/ui-improvements
UI improvements
2026-01-10 18:41:22 +01:00
louis-e
7c808ec352 Address code review feedback 2026-01-10 18:36:43 +01:00
louis-e
b757c5acf4 Better icons and map overlay fix 2026-01-10 17:56:37 +01:00
louis-e
ced5fc274e More landuse variations 2026-01-10 17:27:10 +01:00
Louis Erbkamm
295ca415d7 Merge pull request #660 from louis-e/copilot/fix-spawn-point-y-coordinate
Fix spawn point Y coordinate update for longitude values outside ±90°
2026-01-10 13:20:46 +01:00
Louis Erbkamm
e2b4ca8bdb Merge branch 'main' into copilot/fix-spawn-point-y-coordinate 2026-01-10 13:15:04 +01:00
Louis Erbkamm
07105f0208 Merge pull request #703 from louis-e/no-internet-warning
Send proper error for no internet
2026-01-08 23:36:36 +01:00
louis-e
ad57fdbc3a Send proper error for no internet 2026-01-08 23:32:39 +01:00
Louis Erbkamm
550870d9e0 Merge pull request #702 from louis-e/better-elevation
Implement more realistic elevation
2026-01-08 23:14:22 +01:00
louis-e
bd693ea007 Reduce log lines 2026-01-08 23:13:46 +01:00
louis-e
ce8f343414 Sample less points in bridge calc 2026-01-08 23:01:12 +01:00
louis-e
f882145780 Improve efficiency of ground generation 2026-01-08 22:42:06 +01:00
louis-e
b52d750935 Address code review feedback 2026-01-08 22:20:17 +01:00
louis-e
4d30899909 Remove snow again 2026-01-08 21:23:18 +01:00
louis-e
311610a717 Use less operations for better efficiency 2026-01-08 20:53:00 +01:00
louis-e
b4902ebc9e Add snow on top of mountains and address code review feedback 2026-01-08 20:39:56 +01:00
louis-e
e5bbb3e4a0 Address code review feedback 2026-01-08 20:03:15 +01:00
louis-e
0238cfe2d0 Implement more realistic elevation 2026-01-08 19:52:03 +01:00
copilot-swe-agent[bot]
0a51b302ee Simplify bbox format comment
Co-authored-by: louis-e <44675238+louis-e@users.noreply.github.com>
2025-12-07 14:22:50 +00:00
copilot-swe-agent[bot]
93dc9f446c Improve comment explaining bbox format conversion
Co-authored-by: louis-e <44675238+louis-e@users.noreply.github.com>
2025-12-07 14:17:56 +00:00
copilot-swe-agent[bot]
e6430f2a04 Fix spawn point Y coordinate bbox format
Co-authored-by: louis-e <44675238+louis-e@users.noreply.github.com>
2025-12-07 14:11:36 +00:00
copilot-swe-agent[bot]
5962decf44 Initial plan 2025-12-07 13:58:19 +00:00
16 changed files with 790 additions and 371 deletions

View File

@@ -69,19 +69,17 @@ pub fn generate_world_with_options(
println!("{} Processing data...", "[4/7]".bold());
// Build highway connectivity map once before processing
let highway_connectivity = highways::build_highway_connectivity_map(&elements);
// Set ground reference in the editor to enable elevation-aware block placement
editor.set_ground(Arc::clone(&ground));
println!("{} Processing terrain...", "[5/7]".bold());
emit_gui_progress_update(25.0, "Processing terrain...");
// Run both precomputations concurrently using rayon::join
// This overlaps highway connectivity map building with flood fill computation
let timeout_ref = args.timeout.as_ref();
let (highway_connectivity, mut flood_fill_cache) = rayon::join(
|| highways::build_highway_connectivity_map(&elements),
|| FloodFillCache::precompute(&elements, timeout_ref),
);
// Pre-compute all flood fills in parallel for better CPU utilization
let mut flood_fill_cache = FloodFillCache::precompute(&elements, args.timeout.as_ref());
println!("Pre-computed {} flood fills", flood_fill_cache.way_count());
// Process data
@@ -264,7 +262,8 @@ pub fn generate_world_with_options(
let total_iterations_grnd: f64 = total_blocks as f64;
let progress_increment_grnd: f64 = 20.0 / total_iterations_grnd;
let groundlayer_block = GRASS_BLOCK;
// Check if terrain elevation is enabled; when disabled, we can skip ground level lookups entirely
let terrain_enabled = ground.elevation_enabled;
// Process ground generation chunk-by-chunk for better cache locality.
// This keeps the same region/chunk HashMap entries hot in CPU cache,
@@ -284,11 +283,19 @@ pub fn generate_world_with_options(
for x in chunk_min_x..=chunk_max_x {
for z in chunk_min_z..=chunk_max_z {
// Get ground level, when terrain is enabled, look it up once per block
// When disabled, use constant ground_level (no function call overhead)
let ground_y = if terrain_enabled {
editor.get_ground_level(x, z)
} else {
args.ground_level
};
// Add default dirt and grass layer if there isn't a stone layer already
if !editor.check_for_block(x, 0, z, Some(&[STONE])) {
editor.set_block(groundlayer_block, x, 0, z, None, None);
editor.set_block(DIRT, x, -1, z, None, None);
editor.set_block(DIRT, x, -2, z, None, None);
if !editor.check_for_block_absolute(x, ground_y, z, Some(&[STONE]), None) {
editor.set_block_absolute(GRASS_BLOCK, x, ground_y, z, None, None);
editor.set_block_absolute(DIRT, x, ground_y - 1, z, None, None);
editor.set_block_absolute(DIRT, x, ground_y - 2, z, None, None);
}
// Fill underground with stone
@@ -300,7 +307,7 @@ pub fn generate_world_with_options(
MIN_Y + 1,
z,
x,
editor.get_absolute_y(x, -3, z),
ground_y - 3,
z,
None,
None,
@@ -350,12 +357,14 @@ pub fn generate_world_with_options(
if world_format == WorldFormat::JavaAnvil {
if let Some(spawn_coords) = &args.spawn_point {
use crate::gui::update_player_spawn_y_after_generation;
// Reconstruct bbox string to match the format that GUI originally provided.
// This ensures LLBBox::from_str() can parse it correctly.
let bbox_string = format!(
"{},{},{},{}",
args.bbox.min().lng(),
args.bbox.min().lat(),
args.bbox.max().lng(),
args.bbox.max().lat()
args.bbox.min().lng(),
args.bbox.max().lat(),
args.bbox.max().lng()
);
if let Err(e) = update_player_spawn_y_after_generation(

View File

@@ -3,37 +3,97 @@ use crate::bresenham::bresenham_line;
use crate::osm_parser::ProcessedWay;
use crate::world_editor::WorldEditor;
// TODO FIX
// TODO FIX - This handles ways with bridge=yes tag (e.g., highway bridges)
#[allow(dead_code)]
pub fn generate_bridges(editor: &mut WorldEditor, element: &ProcessedWay) {
if let Some(_bridge_type) = element.tags.get("bridge") {
let bridge_height = 3; // Fixed height
let bridge_height = 3; // Height above the ground level
// Get start and end node elevations and use MAX for level bridge deck
// Using MAX ensures bridges don't dip when multiple bridge ways meet in a valley
let bridge_deck_ground_y = if element.nodes.len() >= 2 {
let start_node = &element.nodes[0];
let end_node = &element.nodes[element.nodes.len() - 1];
let start_y = editor.get_ground_level(start_node.x, start_node.z);
let end_y = editor.get_ground_level(end_node.x, end_node.z);
start_y.max(end_y)
} else {
return; // Need at least 2 nodes for a bridge
};
// Calculate total bridge length for ramp positioning
let total_length: f64 = element
.nodes
.windows(2)
.map(|pair| {
let dx = (pair[1].x - pair[0].x) as f64;
let dz = (pair[1].z - pair[0].z) as f64;
(dx * dx + dz * dz).sqrt()
})
.sum();
if total_length == 0.0 {
return;
}
let mut accumulated_length: f64 = 0.0;
for i in 1..element.nodes.len() {
let prev = &element.nodes[i - 1];
let cur = &element.nodes[i];
let segment_dx = (cur.x - prev.x) as f64;
let segment_dz = (cur.z - prev.z) as f64;
let segment_length = (segment_dx * segment_dx + segment_dz * segment_dz).sqrt();
let points = bresenham_line(prev.x, 0, prev.z, cur.x, 0, cur.z);
let total_length = points.len();
let ramp_length = 6; // Length of ramp at each end
let ramp_length = (total_length * 0.15).clamp(6.0, 20.0) as usize; // 15% of bridge, min 6, max 20 blocks
for (idx, (x, _, z)) in points.iter().enumerate() {
let height = if idx < ramp_length {
// Calculate progress along this segment
let segment_progress = if points.len() > 1 {
idx as f64 / (points.len() - 1) as f64
} else {
0.0
};
// Calculate overall progress along the entire bridge
let point_distance = accumulated_length + segment_progress * segment_length;
let overall_progress = (point_distance / total_length).clamp(0.0, 1.0);
let total_len_usize = total_length as usize;
let overall_idx = (overall_progress * total_len_usize as f64) as usize;
// Calculate ramp height offset
let ramp_offset = if overall_idx < ramp_length {
// Start ramp (rising)
(idx * bridge_height) / ramp_length
} else if idx >= total_length - ramp_length {
(overall_idx as f64 * bridge_height as f64 / ramp_length as f64) as i32
} else if overall_idx >= total_len_usize.saturating_sub(ramp_length) {
// End ramp (descending)
((total_length - idx) * bridge_height) / ramp_length
let dist_from_end = total_len_usize - overall_idx;
(dist_from_end as f64 * bridge_height as f64 / ramp_length as f64) as i32
} else {
// Middle section (constant height)
bridge_height
};
// Use fixed bridge deck height (max of endpoints) plus ramp offset
let bridge_y = bridge_deck_ground_y + ramp_offset;
// Place bridge blocks
for dx in -2..=2 {
editor.set_block(LIGHT_GRAY_CONCRETE, *x + dx, height as i32, *z, None, None);
editor.set_block_absolute(
LIGHT_GRAY_CONCRETE,
*x + dx,
bridge_y,
*z,
None,
None,
);
}
}
accumulated_length += segment_length;
}
}
}

View File

@@ -1525,6 +1525,8 @@ pub fn generate_building_from_relation(
}
/// Generates a bridge structure, paying attention to the "level" tag.
/// Bridge deck is interpolated between start and end point elevations to avoid
/// being dragged down by valleys underneath.
fn generate_bridge(
editor: &mut WorldEditor,
element: &ProcessedWay,
@@ -1534,7 +1536,7 @@ fn generate_bridge(
let floor_block: Block = STONE;
let railing_block: Block = STONE_BRICKS;
// Calculate bridge level based on the "level" tag (computed once, used throughout)
// Calculate bridge level offset based on the "level" tag
let bridge_y_offset = if let Some(level_str) = element.tags.get("level") {
if let Ok(level) = level_str.parse::<i32>() {
(level * 3) + 1
@@ -1545,21 +1547,37 @@ fn generate_bridge(
1 // Default elevation
};
// Need at least 2 nodes to form a bridge
if element.nodes.len() < 2 {
return;
}
// Get start and end node elevations and use MAX for level bridge deck
// Using MAX ensures bridges don't dip when multiple bridge ways meet in a valley
let start_node = &element.nodes[0];
let end_node = &element.nodes[element.nodes.len() - 1];
let start_y = editor.get_ground_level(start_node.x, start_node.z);
let end_y = editor.get_ground_level(end_node.x, end_node.z);
let bridge_deck_ground_y = start_y.max(end_y);
// Process the nodes to create bridge pathways and railings
let mut previous_node: Option<(i32, i32)> = None;
for node in &element.nodes {
let x: i32 = node.x;
let z: i32 = node.z;
// Create bridge path using Bresenham's line
if let Some(prev) = previous_node {
let bridge_points: Vec<(i32, i32, i32)> =
bresenham_line(prev.0, bridge_y_offset, prev.1, x, bridge_y_offset, z);
let bridge_points: Vec<(i32, i32, i32)> = bresenham_line(prev.0, 0, prev.1, x, 0, z);
for (bx, _, bz) in bridge_points.iter() {
// Use fixed bridge deck height (max of endpoints)
let bridge_y = bridge_deck_ground_y + bridge_y_offset;
for (bx, by, bz) in bridge_points {
// Place railing blocks
editor.set_block(railing_block, bx, by + 1, bz, None, None);
editor.set_block(railing_block, bx, by, bz, None, None);
editor.set_block_absolute(railing_block, *bx, bridge_y + 1, *bz, None, None);
editor.set_block_absolute(railing_block, *bx, bridge_y, *bz, None, None);
}
}
@@ -1569,8 +1587,11 @@ fn generate_bridge(
// Flood fill the area between the bridge path nodes (uses cache)
let bridge_area: Vec<(i32, i32)> = flood_fill_cache.get_or_compute(element, floodfill_timeout);
// Use the same level bridge deck height for filled areas
let floor_y = bridge_deck_ground_y + bridge_y_offset;
// Place floor blocks
for (x, z) in bridge_area {
editor.set_block(floor_block, x, bridge_y_offset, z, None, None);
editor.set_block_absolute(floor_block, x, floor_y, z, None, None);
}
}

View File

@@ -5,12 +5,14 @@ use crate::coordinate_system::cartesian::XZPoint;
use crate::floodfill_cache::FloodFillCache;
use crate::osm_parser::{ProcessedElement, ProcessedWay};
use crate::world_editor::WorldEditor;
use rayon::prelude::*;
use std::collections::HashMap;
/// Type alias for highway connectivity map
pub type HighwayConnectivityMap = HashMap<(i32, i32), Vec<i32>>;
/// Minimum terrain dip (in blocks) below max endpoint elevation to classify a bridge as valley-spanning
const VALLEY_BRIDGE_THRESHOLD: i32 = 7;
/// Generates highways with elevation support based on layer tags and connectivity analysis
pub fn generate_highways(
editor: &mut WorldEditor,
@@ -29,41 +31,39 @@ pub fn generate_highways(
}
/// Build a connectivity map for highway endpoints to determine where slopes are needed.
/// Uses parallel processing for better performance on large element sets.
pub fn build_highway_connectivity_map(elements: &[ProcessedElement]) -> HighwayConnectivityMap {
// Parallel map phase: extract connectivity data from each highway element
let partial_maps: Vec<Vec<((i32, i32), i32)>> = elements
.par_iter()
.filter_map(|element| {
if let ProcessedElement::Way(way) = element {
if way.tags.contains_key("highway") && !way.nodes.is_empty() {
let layer_value = way
.tags
.get("layer")
.and_then(|layer| layer.parse::<i32>().ok())
.unwrap_or(0);
let mut connectivity_map: HashMap<(i32, i32), Vec<i32>> = HashMap::new();
// Treat negative layers as ground level (0) for connectivity
let layer_value = if layer_value < 0 { 0 } else { layer_value };
for element in elements {
if let ProcessedElement::Way(way) = element {
if way.tags.contains_key("highway") {
let layer_value = way
.tags
.get("layer")
.and_then(|layer| layer.parse::<i32>().ok())
.unwrap_or(0);
// Treat negative layers as ground level (0) for connectivity
let layer_value = if layer_value < 0 { 0 } else { layer_value };
// Add connectivity for start and end nodes
if !way.nodes.is_empty() {
let start_node = &way.nodes[0];
let end_node = &way.nodes[way.nodes.len() - 1];
let start_coord = (start_node.x, start_node.z);
let end_coord = (end_node.x, end_node.z);
return Some(vec![(start_coord, layer_value), (end_coord, layer_value)]);
connectivity_map
.entry(start_coord)
.or_default()
.push(layer_value);
connectivity_map
.entry(end_coord)
.or_default()
.push(layer_value);
}
}
None
})
.collect();
// Sequential reduce phase: merge all partial results into final map
let mut connectivity_map: HashMap<(i32, i32), Vec<i32>> = HashMap::new();
for entries in partial_maps {
for (coord, layer) in entries {
connectivity_map.entry(coord).or_default().push(layer);
}
}
@@ -163,6 +163,11 @@ fn generate_highways_internal(
let mut add_outline = false;
let scale_factor = args.scale;
// Check if this is a bridge - bridges need special elevation handling
// to span across valleys instead of following terrain
// Accept any bridge tag value except "no" (e.g., "yes", "viaduct", "aqueduct", etc.)
let is_bridge = element.tags().get("bridge").is_some_and(|v| v != "no");
// Parse the layer value for elevation calculation
let layer_value = element
.tags()
@@ -252,6 +257,7 @@ fn generate_highways_internal(
let base_elevation = layer_value * LAYER_HEIGHT_STEP;
// Check if we need slopes at start and end
// This is used for overpasses that need ramps to ground-level roads
let needs_start_slope =
should_add_slope_at_node(&way.nodes[0], layer_value, highway_connectivity);
let needs_end_slope = should_add_slope_at_node(
@@ -260,10 +266,67 @@ fn generate_highways_internal(
highway_connectivity,
);
// Calculate total way length for slope distribution
// Calculate total way length for slope distribution (needed before valley bridge check)
let total_way_length = calculate_way_length(way);
// Check if this is a short isolated elevated segment - if so, treat as ground level
// For bridges: detect if this spans a valley by checking terrain profile
// A valley bridge has terrain that dips significantly below the endpoints
// Skip valley detection entirely if terrain is disabled (no valleys in flat terrain)
// Skip very short bridges (< 25 blocks) as they're unlikely to span significant valleys
let terrain_enabled = editor
.get_ground()
.map(|g| g.elevation_enabled)
.unwrap_or(false);
let (is_valley_bridge, bridge_deck_y) =
if is_bridge && terrain_enabled && way.nodes.len() >= 2 && total_way_length >= 25 {
let start_node = &way.nodes[0];
let end_node = &way.nodes[way.nodes.len() - 1];
let start_y = editor.get_ground_level(start_node.x, start_node.z);
let end_y = editor.get_ground_level(end_node.x, end_node.z);
let max_endpoint_y = start_y.max(end_y);
// Sample terrain at middle nodes only (excluding endpoints we already have)
// This avoids redundant get_ground_level() calls
let middle_nodes = &way.nodes[1..way.nodes.len().saturating_sub(1)];
let sampled_min = if middle_nodes.is_empty() {
// No middle nodes, just use endpoints
start_y.min(end_y)
} else {
// Sample up to 3 middle points (5 total with endpoints) for performance
// Valleys are wide terrain features, so sparse sampling is sufficient
let sample_count = middle_nodes.len().min(3);
let step = if sample_count > 1 {
(middle_nodes.len() - 1) / (sample_count - 1)
} else {
1
};
middle_nodes
.iter()
.step_by(step.max(1))
.map(|node| editor.get_ground_level(node.x, node.z))
.min()
.unwrap_or(max_endpoint_y)
};
// Include endpoint elevations in the minimum calculation
let min_terrain_y = sampled_min.min(start_y).min(end_y);
// If ANY sampled point along the bridge is significantly lower than the max endpoint,
// treat as valley bridge
let is_valley = min_terrain_y < max_endpoint_y - VALLEY_BRIDGE_THRESHOLD;
if is_valley {
(true, max_endpoint_y)
} else {
(false, 0)
}
} else {
(false, 0)
};
// Check if this is a short isolated elevated segment (layer > 0), if so, treat as ground level
let is_short_isolated_elevated =
needs_start_slope && needs_end_slope && layer_value > 0 && total_way_length <= 35;
@@ -300,17 +363,28 @@ fn generate_highways_internal(
let gap_length: i32 = (5.0 * scale_factor).ceil() as i32;
for (point_index, (x, _, z)) in bresenham_points.iter().enumerate() {
// Calculate Y elevation for this point based on slopes and layer
let current_y = calculate_point_elevation(
segment_index,
point_index,
segment_length,
total_segments,
effective_elevation,
effective_start_slope,
effective_end_slope,
slope_length,
);
// Calculate Y elevation for this point
// For valley bridges: use fixed deck height (max of endpoints) to stay level
// For overpasses and regular roads: use terrain-relative elevation with slopes
let (current_y, use_absolute_y) = if is_valley_bridge {
// Valley bridge deck is level at the maximum endpoint elevation
// Don't add base_elevation - the layer tag indicates it's above water/road,
// not that it should be higher than the terrain endpoints
(bridge_deck_y, true)
} else {
// Regular road or overpass: use terrain-relative calculation with ramps
let y = calculate_point_elevation(
segment_index,
point_index,
segment_length,
total_segments,
effective_elevation,
effective_start_slope,
effective_end_slope,
slope_length,
);
(y, false)
};
// Draw the road surface for the entire width
for dx in -block_range..=block_range {
@@ -326,12 +400,32 @@ fn generate_highways_internal(
let is_horizontal: bool = (x2 - x1).abs() >= (z2 - z1).abs();
if is_horizontal {
if set_x % 2 < 1 {
editor.set_block(
WHITE_CONCRETE,
if use_absolute_y {
editor.set_block_absolute(
WHITE_CONCRETE,
set_x,
current_y,
set_z,
Some(&[BLACK_CONCRETE]),
None,
);
} else {
editor.set_block(
WHITE_CONCRETE,
set_x,
current_y,
set_z,
Some(&[BLACK_CONCRETE]),
None,
);
}
} else if use_absolute_y {
editor.set_block_absolute(
BLACK_CONCRETE,
set_x,
current_y,
set_z,
Some(&[BLACK_CONCRETE]),
None,
None,
);
} else {
@@ -345,12 +439,32 @@ fn generate_highways_internal(
);
}
} else if set_z % 2 < 1 {
editor.set_block(
WHITE_CONCRETE,
if use_absolute_y {
editor.set_block_absolute(
WHITE_CONCRETE,
set_x,
current_y,
set_z,
Some(&[BLACK_CONCRETE]),
None,
);
} else {
editor.set_block(
WHITE_CONCRETE,
set_x,
current_y,
set_z,
Some(&[BLACK_CONCRETE]),
None,
);
}
} else if use_absolute_y {
editor.set_block_absolute(
BLACK_CONCRETE,
set_x,
current_y,
set_z,
Some(&[BLACK_CONCRETE]),
None,
None,
);
} else {
@@ -363,6 +477,15 @@ fn generate_highways_internal(
None,
);
}
} else if use_absolute_y {
editor.set_block_absolute(
block_type,
set_x,
current_y,
set_z,
None,
Some(&[BLACK_CONCRETE, WHITE_CONCRETE]),
);
} else {
editor.set_block(
block_type,
@@ -374,30 +497,53 @@ fn generate_highways_internal(
);
}
// Add stone brick foundation underneath elevated highways for thickness
if effective_elevation > 0 && current_y > 0 {
// Add stone brick foundation underneath elevated highways/bridges for thickness
if (effective_elevation > 0 || use_absolute_y) && current_y > 0 {
// Add 1 layer of stone bricks underneath the highway surface
editor.set_block(
STONE_BRICKS,
set_x,
current_y - 1,
set_z,
None,
None,
);
if use_absolute_y {
editor.set_block_absolute(
STONE_BRICKS,
set_x,
current_y - 1,
set_z,
None,
None,
);
} else {
editor.set_block(
STONE_BRICKS,
set_x,
current_y - 1,
set_z,
None,
None,
);
}
}
// Add support pillars for elevated highways
if effective_elevation != 0 && current_y > 0 {
add_highway_support_pillar(
editor,
set_x,
current_y,
set_z,
dx,
dz,
block_range,
);
// Add support pillars for elevated highways/bridges
if (effective_elevation != 0 || use_absolute_y) && current_y > 0 {
if use_absolute_y {
add_highway_support_pillar_absolute(
editor,
set_x,
current_y,
set_z,
dx,
dz,
block_range,
);
} else {
add_highway_support_pillar(
editor,
set_x,
current_y,
set_z,
dx,
dz,
block_range,
);
}
}
}
}
@@ -408,27 +554,49 @@ fn generate_highways_internal(
for dz in -block_range..=block_range {
let outline_x = x - block_range - 1;
let outline_z = z + dz;
editor.set_block(
LIGHT_GRAY_CONCRETE,
outline_x,
current_y,
outline_z,
None,
None,
);
if use_absolute_y {
editor.set_block_absolute(
LIGHT_GRAY_CONCRETE,
outline_x,
current_y,
outline_z,
None,
None,
);
} else {
editor.set_block(
LIGHT_GRAY_CONCRETE,
outline_x,
current_y,
outline_z,
None,
None,
);
}
}
// Right outline
for dz in -block_range..=block_range {
let outline_x = x + block_range + 1;
let outline_z = z + dz;
editor.set_block(
LIGHT_GRAY_CONCRETE,
outline_x,
current_y,
outline_z,
None,
None,
);
if use_absolute_y {
editor.set_block_absolute(
LIGHT_GRAY_CONCRETE,
outline_x,
current_y,
outline_z,
None,
None,
);
} else {
editor.set_block(
LIGHT_GRAY_CONCRETE,
outline_x,
current_y,
outline_z,
None,
None,
);
}
}
}
@@ -437,14 +605,25 @@ fn generate_highways_internal(
if stripe_length < dash_length {
let stripe_x: i32 = *x;
let stripe_z: i32 = *z;
editor.set_block(
WHITE_CONCRETE,
stripe_x,
current_y,
stripe_z,
Some(&[BLACK_CONCRETE]),
None,
);
if use_absolute_y {
editor.set_block_absolute(
WHITE_CONCRETE,
stripe_x,
current_y,
stripe_z,
Some(&[BLACK_CONCRETE]),
None,
);
} else {
editor.set_block(
WHITE_CONCRETE,
stripe_x,
current_y,
stripe_z,
Some(&[BLACK_CONCRETE]),
None,
);
}
}
// Increment stripe_length and reset after completing a dash and gap
@@ -588,6 +767,46 @@ fn add_highway_support_pillar(
}
}
/// Add support pillars for bridges using absolute Y coordinates
/// Pillars extend from ground level up to the bridge deck
fn add_highway_support_pillar_absolute(
editor: &mut WorldEditor,
x: i32,
bridge_deck_y: i32,
z: i32,
dx: i32,
dz: i32,
_block_range: i32, // Keep for future use
) {
// Only add pillars at specific intervals and positions
if dx == 0 && dz == 0 && (x + z) % 8 == 0 {
// Get the actual ground level at this position
let ground_y = editor.get_ground_level(x, z);
// Add pillar from ground up to bridge deck
// Only if the bridge is actually above the ground
if bridge_deck_y > ground_y {
for y in (ground_y + 1)..bridge_deck_y {
editor.set_block_absolute(STONE_BRICKS, x, y, z, None, None);
}
// Add pillar base at ground level
for base_dx in -1..=1 {
for base_dz in -1..=1 {
editor.set_block_absolute(
STONE_BRICKS,
x + base_dx,
ground_y,
z + base_dz,
None,
None,
);
}
}
}
}
}
/// Generates a siding using stone brick slabs
pub fn generate_siding(editor: &mut WorldEditor, element: &ProcessedWay) {
let mut previous_node: Option<XZPoint> = None;

View File

@@ -17,6 +17,9 @@ pub fn generate_landuse(
let binding: String = "".to_string();
let landuse_tag: &String = element.tags.get("landuse").unwrap_or(&binding);
// Use deterministic RNG seeded by element ID for consistent results across region boundaries
let mut rng = element_rng(element.id);
let block_type = match landuse_tag.as_str() {
"greenfield" | "meadow" | "grass" | "orchard" | "forest" => GRASS_BLOCK,
"farmland" => FARMLAND,
@@ -28,10 +31,10 @@ pub fn generate_landuse(
if residential_tag == "rural" {
GRASS_BLOCK
} else {
STONE_BRICKS
STONE_BRICKS // Placeholder, will be randomized per-block
}
}
"commercial" => SMOOTH_STONE,
"commercial" => SMOOTH_STONE, // Placeholder, will be randomized per-block
"education" => POLISHED_ANDESITE,
"religious" => POLISHED_ANDESITE,
"industrial" => COBBLESTONE,
@@ -54,16 +57,42 @@ pub fn generate_landuse(
let floor_area: Vec<(i32, i32)> =
flood_fill_cache.get_or_compute(element, args.timeout.as_ref());
// Use deterministic RNG seeded by element ID for consistent results across region boundaries
let mut rng = element_rng(element.id);
for (x, z) in floor_area {
if landuse_tag == "traffic_island" {
editor.set_block(block_type, x, 1, z, None, None);
} else if landuse_tag == "construction" || landuse_tag == "railway" {
editor.set_block(block_type, x, 0, z, None, Some(&[SPONGE]));
// Apply per-block randomness for certain landuse types
let actual_block = if landuse_tag == "residential" && block_type == STONE_BRICKS {
// Urban residential: mix of stone bricks, cracked stone bricks, stone, cobblestone
let random_value = rng.gen_range(0..100);
if random_value < 72 {
STONE_BRICKS
} else if random_value < 87 {
CRACKED_STONE_BRICKS
} else if random_value < 92 {
STONE
} else {
COBBLESTONE
}
} else if landuse_tag == "commercial" {
// Commercial: mix of smooth stone, stone, cobblestone, stone bricks
let random_value = rng.gen_range(0..100);
if random_value < 40 {
SMOOTH_STONE
} else if random_value < 70 {
STONE_BRICKS
} else if random_value < 90 {
STONE
} else {
COBBLESTONE
}
} else {
editor.set_block(block_type, x, 0, z, None, None);
block_type
};
if landuse_tag == "traffic_island" {
editor.set_block(actual_block, x, 1, z, None, None);
} else if landuse_tag == "construction" || landuse_tag == "railway" {
editor.set_block(actual_block, x, 0, z, None, Some(&[SPONGE]));
} else {
editor.set_block(actual_block, x, 0, z, None, None);
}
// Add specific features for different landuse types

View File

@@ -7,8 +7,6 @@ use std::path::{Path, PathBuf};
/// Maximum Y coordinate in Minecraft (build height limit)
const MAX_Y: i32 = 319;
/// Scale factor for converting real elevation to Minecraft heights
const BASE_HEIGHT_SCALE: f64 = 0.7;
/// AWS S3 Terrarium tiles endpoint (no API key required)
const AWS_TERRARIUM_URL: &str =
"https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png";
@@ -318,16 +316,11 @@ pub fn fetch_elevation_data(
// This smooths terrain proportionally while preserving more detail.
let sigma: f64 = BASE_SIGMA_REF * (grid_size / BASE_GRID_REF).sqrt();
let blur_percentage: f64 = (sigma / grid_size) * 100.0;
eprintln!(
//let blur_percentage: f64 = (sigma / grid_size) * 100.0;
/*eprintln!(
"Elevation blur: grid={}x{}, sigma={:.2}, blur_percentage={:.2}%",
grid_width, grid_height, sigma, blur_percentage
);
/* eprintln!(
"Grid: {}x{}, Blur sigma: {:.2}",
grid_width, grid_height, sigma
); */
);*/
// Continue with the existing blur and conversion to Minecraft heights...
let blurred_heights: Vec<Vec<f64>> = apply_gaussian_blur(&height_grid, sigma);
@@ -358,7 +351,7 @@ pub fn fetch_elevation_data(
}
}
eprintln!("Height data range: {min_height} to {max_height} m");
//eprintln!("Height data range: {min_height} to {max_height} m");
if extreme_low_count > 0 {
eprintln!(
"WARNING: Found {extreme_low_count} pixels with extremely low elevations (< -1000m)"
@@ -371,35 +364,58 @@ pub fn fetch_elevation_data(
}
let height_range: f64 = max_height - min_height;
// Apply scale factor to height scaling
let mut height_scale: f64 = BASE_HEIGHT_SCALE * scale.sqrt(); // sqrt to make height scaling less extreme
let mut scaled_range: f64 = height_range * height_scale;
// Adaptive scaling: ensure we don't exceed reasonable Y range
let available_y_range = (MAX_Y - ground_level) as f64;
let safety_margin = 0.9; // Use 90% of available range
let max_allowed_range = available_y_range * safety_margin;
// Realistic height scaling: 1 meter of real elevation = scale blocks in Minecraft
// At scale=1.0, 1 meter = 1 block (realistic 1:1 mapping)
// At scale=2.0, 1 meter = 2 blocks (exaggerated for larger worlds)
let ideal_scaled_range: f64 = height_range * scale;
if scaled_range > max_allowed_range {
let adjustment_factor = max_allowed_range / scaled_range;
height_scale *= adjustment_factor;
scaled_range = height_range * height_scale;
// Calculate available Y range in Minecraft (from ground_level to MAX_Y)
// Leave a buffer at the top for buildings, trees, and other structures
const TERRAIN_HEIGHT_BUFFER: i32 = 15;
let available_y_range: f64 = (MAX_Y - TERRAIN_HEIGHT_BUFFER - ground_level) as f64;
// Determine final height scale:
// - Use realistic 1:1 (times scale) if terrain fits within Minecraft limits
// - Only compress if the terrain would exceed the build height
let scaled_range: f64 = if ideal_scaled_range <= available_y_range {
// Terrain fits! Use realistic scaling
eprintln!(
"Height range too large, applying scaling adjustment factor: {adjustment_factor:.3}"
"Realistic elevation: {:.1}m range fits in {} available blocks",
height_range, available_y_range as i32
);
eprintln!("Adjusted scaled range: {scaled_range:.1} blocks");
}
ideal_scaled_range
} else {
// Terrain too tall, compress to fit within Minecraft limits
let compression_factor: f64 = available_y_range / height_range;
let compressed_range: f64 = height_range * compression_factor;
eprintln!(
"Elevation compressed: {:.1}m range -> {:.0} blocks ({:.2}:1 ratio, 1 block = {:.2}m)",
height_range,
compressed_range,
height_range / compressed_range,
compressed_range / height_range
);
compressed_range
};
// Convert to scaled Minecraft Y coordinates
// Lowest real elevation maps to ground_level, highest maps to ground_level + scaled_range
for row in blurred_heights {
let mc_row: Vec<i32> = row
.iter()
.map(|&h| {
// Scale the height differences
let relative_height: f64 = (h - min_height) / height_range;
// Calculate relative position within the elevation range (0.0 to 1.0)
let relative_height: f64 = if height_range > 0.0 {
(h - min_height) / height_range
} else {
0.0
};
// Scale to Minecraft blocks and add to ground level
let scaled_height: f64 = relative_height * scaled_range;
// With terrain enabled, ground_level is used as the MIN_Y for terrain
((ground_level as f64 + scaled_height).round() as i32).clamp(ground_level, MAX_Y)
// Clamp to valid Minecraft Y range (leave buffer at top for structures)
((ground_level as f64 + scaled_height).round() as i32)
.clamp(ground_level, MAX_Y - TERRAIN_HEIGHT_BUFFER)
})
.collect();
mc_heights.push(mc_row);
@@ -413,7 +429,7 @@ pub fn fetch_elevation_data(
max_block_height = max_block_height.max(height);
}
}
eprintln!("Minecraft height data range: {min_block_height} to {max_block_height} blocks");
//eprintln!("Minecraft height data range: {min_block_height} to {max_block_height} blocks");
Ok(ElevationData {
heights: mc_heights,
@@ -573,7 +589,7 @@ fn filter_elevation_outliers(height_grid: &mut [Vec<f64>]) {
let min_reasonable = all_heights[p1_idx];
let max_reasonable = all_heights[p99_idx];
eprintln!("Filtering outliers outside range: {min_reasonable:.1}m to {max_reasonable:.1}m");
//eprintln!("Filtering outliers outside range: {min_reasonable:.1}m to {max_reasonable:.1}m");
let mut outliers_filtered = 0;
@@ -588,7 +604,7 @@ fn filter_elevation_outliers(height_grid: &mut [Vec<f64>]) {
}
if outliers_filtered > 0 {
eprintln!("Filtered {outliers_filtered} elevation outliers, interpolating replacements...");
//eprintln!("Filtered {outliers_filtered} elevation outliers, interpolating replacements...");
// Re-run the NaN filling to interpolate the filtered values
fill_nan_values(height_grid);
}

View File

@@ -1071,10 +1071,9 @@ fn gui_start_generation(
Ok(())
}
Err(e) => {
let error_msg = format!("Failed to fetch data: {e}");
emit_gui_error(&error_msg);
emit_gui_error(&e.to_string());
// Session lock will be automatically released when _session_lock goes out of scope
Err(error_msg)
Err(e.to_string())
}
}
})

View File

@@ -351,7 +351,8 @@ body,
background-position: -31px -2px;
}
.leaflet-draw-toolbar .leaflet-draw-edit-preview.disabled {
.leaflet-draw-toolbar .leaflet-draw-edit-preview.disabled,
.leaflet-draw-toolbar .leaflet-draw-edit-preview.editing-mode {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;

View File

@@ -287,7 +287,7 @@ button:hover {
/* Customization Settings */
.modal {
position: fixed;
z-index: 1000;
z-index: 20001;
left: 0;
top: 0;
width: 100%;
@@ -606,9 +606,12 @@ button:hover {
transition: background-color 0.3s, border-color 0.3s;
}
.settings-button .gear-icon::before {
content: "⚙️";
font-size: 18px;
.settings-button svg {
stroke: white;
width: 22px;
height: 22px;
min-width: 22px;
min-height: 22px;
}
/* Logo Animation */

4
src/gui/index.html vendored
View File

@@ -61,8 +61,8 @@
<div class="button-container">
<button type="button" id="start-button" class="start-button" onclick="startGeneration()" data-localize="start_generation">Start Generation</button>
<button type="button" class="settings-button" onclick="openSettings()">
<i class="gear-icon"></i>
<button type="button" class="settings-button" onclick="openSettings()" aria-label="Settings">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path><circle cx="12" cy="12" r="3"></circle></svg>
</button>
</div>
<br><br>

44
src/gui/js/bbox.js vendored
View File

@@ -564,6 +564,7 @@ $(document).ready(function () {
var worldOverlayEnabled = false;
var worldPreviewAvailable = false;
var sliderControl = null;
var worldOverlayHiddenForEdit = false; // Track if we hid the overlay for edit/delete mode
// Create the opacity slider as a proper Leaflet control
var SliderControl = L.Control.extend({
@@ -722,6 +723,32 @@ $(document).ready(function () {
}
}
// Temporarily hide the overlay (for edit/delete mode)
function hideWorldOverlayTemporarily() {
if (worldOverlay && worldOverlayEnabled) {
worldOverlayHiddenForEdit = true;
map.removeLayer(worldOverlay);
}
// Also visually disable the preview button during edit/delete mode
var btn = document.getElementById('world-preview-btn');
if (btn) {
btn.classList.add('editing-mode');
}
}
// Restore the overlay after edit/delete mode ends
function restoreWorldOverlay() {
if (worldOverlayHiddenForEdit && worldOverlay && worldOverlayEnabled) {
worldOverlay.addTo(map);
worldOverlayHiddenForEdit = false;
}
// Re-enable the preview button
var btn = document.getElementById('world-preview-btn');
if (btn) {
btn.classList.remove('editing-mode');
}
}
// Listen for messages from parent window
window.addEventListener('message', function(event) {
if (event.data && event.data.type === 'changeTileTheme') {
@@ -999,6 +1026,23 @@ $(document).ready(function () {
map.fitBounds(bounds.getBounds());
});
// Hide world preview overlay when entering edit or delete mode
map.on('draw:editstart', function() {
hideWorldOverlayTemporarily();
});
map.on('draw:deletestart', function() {
hideWorldOverlayTemporarily();
});
// Restore world preview overlay when exiting edit or delete mode
map.on('draw:editstop', function() {
restoreWorldOverlay();
});
map.on('draw:deletestop', function() {
restoreWorldOverlay();
});
function display() {
$('#boxbounds').text(formatBounds(bounds.getBounds(), '4326'));
$('#boxboundsmerc').text(formatBounds(bounds.getBounds(), currentproj));

14
src/gui/js/main.js vendored
View File

@@ -250,6 +250,20 @@ function initSettings() {
settingsModal.style.display = "none";
}
// Close settings and license modals on escape key
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
if (settingsModal.style.display === "flex") {
closeSettings();
}
const licenseModal = document.getElementById("license-modal");
if (licenseModal && licenseModal.style.display === "flex") {
closeLicense();
}
}
});
window.openSettings = openSettings;
window.closeSettings = closeSettings;

2
src/gui/maps.html vendored
View File

@@ -26,7 +26,7 @@
<div id="search-container">
<div id="search-box">
<input type="text" id="city-search" placeholder="Search for a city..." autocomplete="off" />
<button id="search-btn">🔍</button>
<button id="search-btn" aria-label="Search"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path></svg></button>
</div>
<div id="search-results"></div>
</div>

View File

@@ -36,19 +36,17 @@ fn download_with_reqwest(url: &str, query: &str) -> Result<String, Box<dyn std::
}
Err(e) => {
if e.is_timeout() {
eprintln!(
"{}",
"Error! Request timed out. Try selecting a smaller area."
.red()
.bold()
);
emit_gui_error("Request timed out. Try selecting a smaller area.");
let msg = "Request timed out. Try selecting a smaller area.";
eprintln!("{}", format!("Error! {msg}").red().bold());
Err(msg.into())
} else if e.is_connect() {
let msg = "No internet connection.";
eprintln!("{}", format!("Error! {msg}").red().bold());
Err(msg.into())
} else {
eprintln!("{}", format!("Error! {e:.52}").red().bold());
emit_gui_error(&format!("{:.52}", e.to_string()));
Err(format!("{e:.52}").into())
}
// Always propagate errors
Err(e.into())
}
}
}

View File

@@ -3,7 +3,7 @@
//! This module handles saving worlds in the Java Edition Anvil (.mca) format.
//! Supports streaming mode for memory-efficient saving of large worlds.
use super::common::{Chunk, ChunkToModify, RegionToModify, Section};
use super::common::{Chunk, ChunkToModify, Section};
use super::WorldEditor;
use crate::block_definitions::GRASS_BLOCK;
use crate::progress::emit_gui_progress_update;
@@ -12,21 +12,73 @@ 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::path::Path;
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 region_dir = self.world_dir.join("region");
let out_path = region_dir.join(format!("r.{}.{}.mca", region_x, region_z));
// Ensure region directory exists before creating region files
std::fs::create_dir_all(&region_dir).expect("Failed to create region directory");
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.
///
/// Uses parallel processing: saves multiple regions concurrently for faster I/O,
/// while still releasing memory after each region is processed.
/// Uses streaming mode: saves regions one at a time and releases memory after each,
/// significantly reducing peak memory usage for large worlds.
pub(super) fn save_java(&mut self) {
println!("{} Saving world...", "[7/7]".bold());
emit_gui_progress_update(90.0, "Saving world...");
@@ -40,12 +92,6 @@ impl<'a> WorldEditor<'a> {
}
let total_regions = self.world.regions.len() as u64;
// Early return if no regions to save (prevents division by zero)
if total_regions == 0 {
return;
}
let save_pb = ProgressBar::new(total_regions);
save_pb.set_style(
ProgressStyle::default_bar()
@@ -56,208 +102,155 @@ impl<'a> WorldEditor<'a> {
.progress_chars("█▓░"),
);
// Ensure region directory exists before parallel processing
let region_dir = self.world_dir.join("region");
std::fs::create_dir_all(&region_dir).expect("Failed to create region directory");
// Streaming mode: Process regions sequentially and release memory after each.
// This significantly reduces peak memory for large worlds (100+ regions).
// For small worlds, the overhead is negligible.
let mut regions_processed: u64 = 0;
// Drain all regions from memory into a Vec for parallel processing
let regions_to_save: Vec<((i32, i32), super::common::RegionToModify)> =
self.world.regions.drain().collect();
// Collect region keys first to allow draining
let region_keys: Vec<(i32, i32)> = self.world.regions.keys().copied().collect();
// Track progress atomically across threads
let regions_processed = AtomicU64::new(0);
let world_dir = self.world_dir.clone();
// Process regions in parallel, each region file is independent
regions_to_save
.into_par_iter()
.for_each(|((region_x, region_z), region_to_modify)| {
// Save this region (creates its own file handle)
save_region_to_file(&world_dir, region_x, region_z, &region_to_modify);
// Update progress atomically
let processed = regions_processed.fetch_add(1, Ordering::Relaxed) + 1;
save_pb.inc(1);
// Emit GUI progress update periodically
let update_interval = (total_regions / 10).max(1);
if processed.is_multiple_of(update_interval) || processed == total_regions {
let progress = 90.0 + (processed as f64 / total_regions as f64) * 9.0;
emit_gui_progress_update(progress, "Saving world...");
}
for (region_x, region_z) in region_keys {
// Remove region from memory - this is the key to memory savings
if let Some(region_to_modify) = self.world.regions.remove(&(region_x, region_z)) {
self.save_single_region(region_x, region_z, &region_to_modify);
// Region memory is freed when region_to_modify goes out of scope here
});
}
regions_processed += 1;
// Update progress at regular intervals
let update_interval = (total_regions / 10).max(1);
if regions_processed.is_multiple_of(update_interval)
|| regions_processed == total_regions
{
let progress = 90.0 + (regions_processed as f64 / total_regions as f64) * 9.0;
emit_gui_progress_update(progress, "Saving world...");
}
save_pb.inc(1);
}
save_pb.finish();
}
}
/// Saves a single region to a file (thread-safe, for parallel processing).
///
/// This is a standalone function that can be called from parallel threads
/// since it only needs the world directory path, not a reference to WorldEditor.
fn save_region_to_file(
world_dir: &Path,
region_x: i32,
region_z: i32,
region_to_modify: &RegionToModify,
) {
// Create region file
let region_dir = world_dir.join("region");
let out_path = region_dir.join(format!("r.{}.{}.mca", region_x, region_z));
/// Saves a single region to disk.
///
/// This is extracted to allow streaming mode to save and release regions one at a time.
fn save_single_region(
&self,
region_x: i32,
region_z: i32,
region_to_modify: &super::common::RegionToModify,
) {
let mut region = self.create_region(region_x, region_z);
let mut ser_buffer = Vec::with_capacity(8192);
const REGION_TEMPLATE: &[u8] = include_bytes!("../../assets/minecraft/region.template");
for (&(chunk_x, chunk_z), chunk_to_modify) in &region_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();
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");
let mut region = Region::from_stream(region_file).expect("Failed to load region");
let mut ser_buffer = Vec::with_capacity(8192);
// First pass: write modified chunks
for (&(chunk_x, chunk_z), chunk_to_modify) in &region_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;
// Parse existing chunk or create new one
let mut chunk: Chunk = if !existing_data.is_empty() {
fastnbt::from_bytes(&existing_data).unwrap()
} else {
// Add new section if it doesn't exist
chunk.sections.push(new_section);
}
}
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(),
}
};
// 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)
// 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)
{
// 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());
// 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);
}
}
} 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());
// 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);
// 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 (create base chunks for empty slots)
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, _) = create_base_chunk(abs_chunk_x, abs_chunk_z);
// 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();
}
}
}
}
/// Helper function to create a base chunk with grass blocks at Y -62 (standalone version)
fn create_base_chunk(abs_chunk_x: i32, abs_chunk_z: i32) -> (Vec<u8>, bool) {
let mut chunk = ChunkToModify::default();
// 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);
// 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);
// 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();
}
}
}
}
// 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)
}
/// Helper function to get entity coordinates

View File

@@ -151,6 +151,19 @@ impl<'a> WorldEditor<'a> {
}
}
/// Get the ground level at a specific world coordinate (without any offset)
#[inline(always)]
pub fn get_ground_level(&self, x: i32, z: i32) -> i32 {
if let Some(ground) = &self.ground {
ground.level(XZPoint::new(
x - self.xzbbox.min_x(),
z - self.xzbbox.min_z(),
))
} else {
0 // Default ground level if no terrain data
}
}
/// Returns the minimum world coordinates
pub fn get_min_coords(&self) -> (i32, i32) {
(self.xzbbox.min_x(), self.xzbbox.min_z())