Compare commits

...

17 Commits

Author SHA1 Message Date
Louis Erbkamm
c7e1fec02c Merge pull request #736 from louis-e/more-processors
More processors
2026-02-01 20:24:49 +01:00
louis-e
fb05e2f2b8 Implement UrbanGroundLookup 2026-02-01 19:42:01 +01:00
louis-e
7015cfff5f Correct comments 2026-02-01 19:30:10 +01:00
louis-e
78ca5a49ce Address code review feedback 2026-02-01 19:19:51 +01:00
louis-e
e265f8fa7e Address code review feedback 2026-02-01 19:07:33 +01:00
louis-e
552f4ab013 Abandon boundary element and add rubber band detection 2026-02-01 19:01:15 +01:00
louis-e
319eb656ee GUI option to disable boundary element 2026-02-01 16:40:47 +01:00
louis-e
11a756ab06 Fix overflow 2026-02-01 16:21:39 +01:00
louis-e
0f93853dcb Enhance urban densitiy calculation speed 2026-02-01 16:13:25 +01:00
louis-e
b4c47f559c Address code review feedback 2026-02-01 16:03:26 +01:00
louis-e
a86e23129b Address code review feedback 2026-02-01 15:52:15 +01:00
louis-e
69b30ef59f Fix underground building:part and cargo fmt 2026-02-01 15:51:21 +01:00
louis-e
1733f5d664 Enhance urban density grid 2026-02-01 15:39:01 +01:00
louis-e
e6b6de27ff Determine boundary validity 2026-02-01 14:12:33 +01:00
louis-e
ac0fc275dc Enhance OSM query 2026-02-01 13:46:52 +01:00
louis-e
de1f52bfaf Fix power base 2026-02-01 13:15:13 +01:00
louis-e
03cc86f3e2 Add more element processors 2026-02-01 03:14:45 +01:00
33 changed files with 1955 additions and 194 deletions

View File

@@ -40,17 +40,23 @@ pub struct Args {
pub terrain: bool,
/// Enable interior generation (optional)
#[arg(long, default_value_t = true, action = clap::ArgAction::SetTrue)]
#[arg(long, default_value_t = true)]
pub interior: bool,
/// Enable roof generation (optional)
#[arg(long, default_value_t = true, action = clap::ArgAction::SetTrue)]
#[arg(long, default_value_t = true)]
pub roof: bool,
/// Enable filling ground (optional)
#[arg(long, default_value_t = false, action = clap::ArgAction::SetFalse)]
#[arg(long, default_value_t = false)]
pub fillground: bool,
/// Enable city boundary ground generation (optional)
/// When enabled, detects building clusters and places stone ground in urban areas.
/// Isolated buildings in rural areas will keep grass around them.
#[arg(long, default_value_t = true)]
pub city_boundaries: bool,
/// Enable debug mode (optional)
#[arg(long)]
pub debug: bool,

View File

@@ -277,6 +277,19 @@ impl Block {
196 => "damaged_anvil",
197 => "large_fern",
198 => "large_fern",
199 => "chain",
200 => "end_rod",
201 => "lightning_rod",
202 => "gold_block",
203 => "sea_lantern",
204 => "orange_concrete",
205 => "orange_wool",
206 => "blue_wool",
207 => "green_concrete",
208 => "brick_wall",
209 => "redstone_block",
210 => "chain",
211 => "chain",
_ => panic!("Invalid id"),
}
}
@@ -505,6 +518,17 @@ impl Block {
map.insert("half".to_string(), Value::String("upper".to_string()));
map
})),
210 => Some(Value::Compound({
let mut map = HashMap::new();
map.insert("axis".to_string(), Value::String("x".to_string()));
map
})),
211 => Some(Value::Compound({
let mut map = HashMap::new();
map.insert("axis".to_string(), Value::String("z".to_string()));
map
})),
_ => None,
}
}
@@ -752,6 +776,19 @@ pub const CHIPPED_ANVIL: Block = Block::new(195);
pub const DAMAGED_ANVIL: Block = Block::new(196);
pub const LARGE_FERN_LOWER: Block = Block::new(197);
pub const LARGE_FERN_UPPER: Block = Block::new(198);
pub const CHAIN: Block = Block::new(199);
pub const END_ROD: Block = Block::new(200);
pub const LIGHTNING_ROD: Block = Block::new(201);
pub const GOLD_BLOCK: Block = Block::new(202);
pub const SEA_LANTERN: Block = Block::new(203);
pub const ORANGE_CONCRETE: Block = Block::new(204);
pub const ORANGE_WOOL: Block = Block::new(205);
pub const BLUE_WOOL: Block = Block::new(206);
pub const GREEN_CONCRETE: Block = Block::new(207);
pub const BRICK_WALL: Block = Block::new(208);
pub const REDSTONE_BLOCK: Block = Block::new(209);
pub const CHAIN_X: Block = Block::new(210);
pub const CHAIN_Z: Block = Block::new(211);
/// Maps a block to its corresponding stair variant
#[inline]

View File

@@ -1,5 +1,5 @@
use crate::args::Args;
use crate::block_definitions::{BEDROCK, DIRT, GRASS_BLOCK, STONE};
use crate::block_definitions::{BEDROCK, DIRT, GRASS_BLOCK, SMOOTH_STONE, STONE};
use crate::coordinate_system::cartesian::XZBBox;
use crate::coordinate_system::geographic::LLBBox;
use crate::element_processing::*;
@@ -10,6 +10,7 @@ use crate::osm_parser::ProcessedElement;
use crate::progress::{emit_gui_progress_update, emit_map_preview_ready, emit_open_mcworld_file};
#[cfg(feature = "gui")]
use crate::telemetry::{send_log, LogLevel};
use crate::urban_ground;
use crate::world_editor::{WorldEditor, WorldFormat};
use colored::Colorize;
use indicatif::{ProgressBar, ProgressStyle};
@@ -85,14 +86,16 @@ pub fn generate_world_with_options(
// Uses a memory-efficient bitmap (~1 bit per coordinate) instead of a HashSet (~24 bytes per coordinate)
let building_footprints = flood_fill_cache.collect_building_footprints(&elements, &xzbbox);
// Partition elements: separate boundary elements for deferred processing
// This avoids cloning by moving elements instead of copying them
let (boundary_elements, other_elements): (Vec<_>, Vec<_>) = elements
.into_iter()
.partition(|element| element.tags().contains_key("boundary"));
// Collect building centroids for urban ground generation (only if enabled)
// This must be done before the processing loop clears the flood fill cache
let building_centroids = if args.city_boundaries {
flood_fill_cache.collect_building_centroids(&elements)
} else {
Vec::new()
};
// Process data
let elements_count: usize = other_elements.len() + boundary_elements.len();
// Process all elements (no longer need to partition boundaries)
let elements_count: usize = elements.len();
let process_pb: ProgressBar = ProgressBar::new(elements_count as u64);
process_pb.set_style(ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{bar:45.white/black}] {pos}/{len} elements ({eta}) {msg}")
@@ -103,8 +106,8 @@ pub fn generate_world_with_options(
let mut current_progress_prcs: f64 = 25.0;
let mut last_emitted_progress: f64 = current_progress_prcs;
// Process non-boundary elements first
for element in other_elements.into_iter() {
// Process all elements
for element in elements.into_iter() {
process_pb.inc(1);
current_progress_prcs += progress_increment_prcs;
if (current_progress_prcs - last_emitted_progress).abs() > 0.25 {
@@ -182,6 +185,8 @@ pub fn generate_world_with_options(
highways::generate_siding(&mut editor, way);
} else if way.tags.contains_key("man_made") {
man_made::generate_man_made(&mut editor, &element, args);
} else if way.tags.contains_key("power") {
power::generate_power(&mut editor, &element);
}
// Release flood fill cache entry for this way
flood_fill_cache.remove_way(way.id);
@@ -215,6 +220,14 @@ pub fn generate_world_with_options(
tourisms::generate_tourisms(&mut editor, node);
} else if node.tags.contains_key("man_made") {
man_made::generate_man_made_nodes(&mut editor, node);
} else if node.tags.contains_key("power") {
power::generate_power_nodes(&mut editor, node);
} else if node.tags.contains_key("historic") {
historic::generate_historic(&mut editor, node);
} else if node.tags.contains_key("emergency") {
emergency::generate_emergency(&mut editor, node);
} else if node.tags.contains_key("advertising") {
advertising::generate_advertising(&mut editor, node);
}
}
ProcessedElement::Relation(rel) => {
@@ -270,32 +283,15 @@ pub fn generate_world_with_options(
process_pb.finish();
// Process deferred boundary elements after all other elements
// This ensures boundaries only fill empty areas, they won't overwrite
// any ground blocks set by landuse, leisure, natural, etc.
for element in boundary_elements.into_iter() {
match &element {
ProcessedElement::Way(way) => {
boundaries::generate_boundary(&mut editor, way, args, &flood_fill_cache);
// Clean up cache entry for consistency with other element processing
flood_fill_cache.remove_way(way.id);
}
ProcessedElement::Relation(rel) => {
boundaries::generate_boundary_from_relation(
&mut editor,
rel,
args,
&flood_fill_cache,
&xzbbox,
);
// Clean up cache entries for consistency with other element processing
let way_ids: Vec<u64> = rel.members.iter().map(|m| m.way.id).collect();
flood_fill_cache.remove_relation_ways(&way_ids);
}
_ => {}
}
// Element is dropped here, freeing its memory immediately
}
// Compute urban ground lookup (if enabled)
// Uses a compact cell-based representation instead of storing all coordinates.
// Memory usage: ~270 KB vs ~560 MB for coordinate-based approach.
let urban_lookup = if args.city_boundaries && !building_centroids.is_empty() {
urban_ground::compute_urban_ground_lookup(building_centroids, &xzbbox)
} else {
urban_ground::UrbanGroundLookup::empty()
};
let has_urban_ground = !urban_lookup.is_empty();
// Drop remaining caches
drop(highway_connectivity);
@@ -353,9 +349,18 @@ pub fn generate_world_with_options(
args.ground_level
};
// Check if this coordinate is in an urban area (O(1) lookup)
let is_urban = has_urban_ground && urban_lookup.is_urban(x, z);
// Add default dirt and grass layer if there isn't a stone layer already
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);
if is_urban {
// Urban area: smooth stone ground
editor.set_block_absolute(SMOOTH_STONE, x, ground_y, z, None, None);
} else {
// Rural/natural area: grass and dirt
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);
}

View File

@@ -0,0 +1,120 @@
//! Processing of advertising elements.
//!
//! This module handles advertising-related OSM elements including:
//! - `advertising=column` - Cylindrical advertising columns (Litfaßsäule)
//! - `advertising=flag` - Advertising flags on poles
//! - `advertising=poster_box` - Illuminated poster display boxes
use crate::block_definitions::*;
use crate::deterministic_rng::element_rng;
use crate::osm_parser::ProcessedNode;
use crate::world_editor::WorldEditor;
use rand::Rng;
/// Generate advertising structures from node elements
pub fn generate_advertising(editor: &mut WorldEditor, node: &ProcessedNode) {
// Skip if 'layer' or 'level' is negative in the tags
if let Some(layer) = node.tags.get("layer") {
if layer.parse::<i32>().unwrap_or(0) < 0 {
return;
}
}
if let Some(level) = node.tags.get("level") {
if level.parse::<i32>().unwrap_or(0) < 0 {
return;
}
}
if let Some(advertising_type) = node.tags.get("advertising") {
match advertising_type.as_str() {
"column" => generate_advertising_column(editor, node),
"flag" => generate_advertising_flag(editor, node),
"poster_box" => generate_poster_box(editor, node),
_ => {}
}
}
}
/// Generate an advertising column (Litfaßsäule)
///
/// Creates a simple advertising column.
fn generate_advertising_column(editor: &mut WorldEditor, node: &ProcessedNode) {
let x = node.x;
let z = node.z;
// Two green concrete blocks stacked
editor.set_block(GREEN_CONCRETE, x, 1, z, None, None);
editor.set_block(GREEN_CONCRETE, x, 2, z, None, None);
// Stone brick slab on top
editor.set_block(STONE_BRICK_SLAB, x, 3, z, None, None);
}
/// Generate an advertising flag
///
/// Creates a flagpole with a banner/flag for advertising.
fn generate_advertising_flag(editor: &mut WorldEditor, node: &ProcessedNode) {
let x = node.x;
let z = node.z;
// Use deterministic RNG for flag color
let mut rng = element_rng(node.id);
// Get height from tags or default
let height = node
.tags
.get("height")
.and_then(|h| h.parse::<i32>().ok())
.unwrap_or(6)
.clamp(4, 12);
// Flagpole
for y in 1..=height {
editor.set_block(IRON_BARS, x, y, z, None, None);
}
// Flag/banner at top (using colored wool)
// Random bright advertising colors
let flag_colors = [
RED_WOOL,
YELLOW_WOOL,
BLUE_WOOL,
GREEN_WOOL,
ORANGE_WOOL,
WHITE_WOOL,
];
let flag_block = flag_colors[rng.gen_range(0..flag_colors.len())];
// Flag extends to one side (2-3 blocks)
let flag_length = 3;
for dx in 1..=flag_length {
editor.set_block(flag_block, x + dx, height, z, None, None);
editor.set_block(flag_block, x + dx, height - 1, z, None, None);
}
// Finial at top
editor.set_block(IRON_BLOCK, x, height + 1, z, None, None);
}
/// Generate a poster box (city light / lollipop display)
///
/// Creates an illuminated poster display box on a pole.
fn generate_poster_box(editor: &mut WorldEditor, node: &ProcessedNode) {
let x = node.x;
let z = node.z;
// Y=1: Two iron bars next to each other
editor.set_block(IRON_BARS, x, 1, z, None, None);
editor.set_block(IRON_BARS, x + 1, 1, z, None, None);
// Y=2 and Y=3: Two sea lanterns
editor.set_block(SEA_LANTERN, x, 2, z, None, None);
editor.set_block(SEA_LANTERN, x + 1, 2, z, None, None);
editor.set_block(SEA_LANTERN, x, 3, z, None, None);
editor.set_block(SEA_LANTERN, x + 1, 3, z, None, None);
// Y=4: Two polished stone brick slabs
editor.set_block(STONE_BRICK_SLAB, x, 4, z, None, None);
editor.set_block(STONE_BRICK_SLAB, x + 1, 4, z, None, None);
}

View File

@@ -1,127 +0,0 @@
//! Processing of administrative and urban boundaries.
//!
//! This module handles boundary elements that define urban areas (cities, boroughs, etc.)
//! and sets appropriate ground blocks for them.
//!
//! Boundaries are processed last but only fill empty areas, allowing more specific
//! landuse areas (parks, residential, etc.) to take priority over the general
//! urban ground.
use crate::args::Args;
use crate::block_definitions::*;
use crate::clipping::clip_way_to_bbox;
use crate::coordinate_system::cartesian::XZBBox;
use crate::floodfill_cache::FloodFillCache;
use crate::osm_parser::{ProcessedMemberRole, ProcessedNode, ProcessedRelation, ProcessedWay};
use crate::world_editor::WorldEditor;
/// Checks if a boundary element represents an urban area that should have stone ground.
///
/// Returns true for:
/// - `boundary=administrative` with `admin_level >= 8` (city/borough level or smaller)
/// - `boundary=low_emission_zone` (urban traffic zones)
/// - `boundary=limited_traffic_zone` (urban traffic zones)
/// - `boundary=special_economic_zone` (developed industrial/commercial zones)
/// - `boundary=political` (electoral districts, usually urban)
fn is_urban_boundary(tags: &std::collections::HashMap<String, String>) -> bool {
let Some(boundary_value) = tags.get("boundary") else {
return false;
};
match boundary_value.as_str() {
"administrative" => {
// Only consider city-level or smaller (admin_level >= 8)
// admin_level 2 = country, 4 = state, 6 = county, 8 = city/municipality
if let Some(admin_level_str) = tags.get("admin_level") {
if let Ok(admin_level) = admin_level_str.parse::<u8>() {
return admin_level >= 8;
}
}
false
}
// Urban zones that should have stone ground
"low_emission_zone" | "limited_traffic_zone" | "special_economic_zone" | "political" => {
true
}
// Natural/protected areas should keep grass - don't process these
// "national_park" | "protected_area" | "forest" | "forest_compartment" | "aboriginal_lands"
// Statistical/administrative-only boundaries - don't affect ground
// "census" | "statistical" | "postal_code" | "timezone" | "disputed" | "maritime" | etc.
_ => false,
}
}
/// Generate ground blocks for an urban boundary way.
pub fn generate_boundary(
editor: &mut WorldEditor,
element: &ProcessedWay,
args: &Args,
flood_fill_cache: &FloodFillCache,
) {
// Check if this is an urban boundary
if !is_urban_boundary(&element.tags) {
return;
}
// Get the area of the boundary element using cache
let floor_area: Vec<(i32, i32)> =
flood_fill_cache.get_or_compute(element, args.timeout.as_ref());
// Fill the area with smooth stone as ground block
// Use None, None to only set where no block exists yet - don't overwrite anything
for (x, z) in floor_area {
editor.set_block(SMOOTH_STONE, x, 0, z, None, None);
}
}
/// Generate ground blocks for an urban boundary relation.
pub fn generate_boundary_from_relation(
editor: &mut WorldEditor,
rel: &ProcessedRelation,
args: &Args,
flood_fill_cache: &FloodFillCache,
xzbbox: &XZBBox,
) {
// Check if this is an urban boundary
if !is_urban_boundary(&rel.tags) {
return;
}
// Collect outer ways (unclipped) for merging
let mut outers: Vec<Vec<ProcessedNode>> = rel
.members
.iter()
.filter(|m| m.role == ProcessedMemberRole::Outer)
.map(|m| m.way.nodes.clone())
.collect();
if outers.is_empty() {
return;
}
// Merge way segments into closed rings
super::merge_way_segments(&mut outers);
// Clip each merged ring to bbox and process
for ring in outers {
if ring.len() < 3 {
continue;
}
// Clip the merged ring to bbox
let clipped_nodes = clip_way_to_bbox(&ring, xzbbox);
if clipped_nodes.len() < 3 {
continue;
}
// Create a ProcessedWay for the clipped ring
let clipped_way = ProcessedWay {
id: rel.id,
nodes: clipped_nodes,
tags: rel.tags.clone(),
};
// Generate boundary area from clipped way
generate_boundary(editor, &clipped_way, args, flood_fill_cache);
}
}

View File

@@ -138,16 +138,17 @@ pub fn generate_buildings(
];
let accent_block = accent_blocks[rng.gen_range(0..accent_blocks.len())];
// Skip if 'layer' or 'level' is negative in the tags
if let Some(layer) = element.tags.get("layer") {
if layer.parse::<i32>().unwrap_or(0) < 0 {
return;
// Skip building:part if 'layer' or 'level' is negative (underground parts)
if element.tags.contains_key("building:part") {
if let Some(layer) = element.tags.get("layer") {
if layer.parse::<i32>().unwrap_or(0) < 0 {
return;
}
}
}
if let Some(level) = element.tags.get("level") {
if level.parse::<i32>().unwrap_or(0) < 0 {
return;
if let Some(level) = element.tags.get("level") {
if level.parse::<i32>().unwrap_or(0) < 0 {
return;
}
}
}
@@ -1516,6 +1517,20 @@ pub fn generate_building_from_relation(
args: &Args,
flood_fill_cache: &FloodFillCache,
) {
// Skip building:part relations if layer or level is negative (underground parts)
if relation.tags.contains_key("building:part") {
if let Some(layer) = relation.tags.get("layer") {
if layer.parse::<i32>().unwrap_or(0) < 0 {
return;
}
}
if let Some(level) = relation.tags.get("level") {
if level.parse::<i32>().unwrap_or(0) < 0 {
return;
}
}
}
// Extract levels from relation tags
let relation_levels = relation
.tags

View File

@@ -0,0 +1,55 @@
//! Processing of emergency infrastructure elements.
//!
//! This module handles emergency-related OSM elements including:
//! - `emergency=fire_hydrant` - Fire hydrants
use crate::block_definitions::*;
use crate::osm_parser::ProcessedNode;
use crate::world_editor::WorldEditor;
/// Generate emergency infrastructure from node elements
pub fn generate_emergency(editor: &mut WorldEditor, node: &ProcessedNode) {
// Skip if 'layer' or 'level' is negative in the tags
if let Some(layer) = node.tags.get("layer") {
if layer.parse::<i32>().unwrap_or(0) < 0 {
return;
}
}
if let Some(level) = node.tags.get("level") {
if level.parse::<i32>().unwrap_or(0) < 0 {
return;
}
}
if let Some(emergency_type) = node.tags.get("emergency") {
if emergency_type.as_str() == "fire_hydrant" {
generate_fire_hydrant(editor, node)
}
}
}
/// Generate a fire hydrant
///
/// Creates a simple fire hydrant structure using brick wall with redstone block on top.
/// Skips underground, wall-mounted, and pond hydrant types.
fn generate_fire_hydrant(editor: &mut WorldEditor, node: &ProcessedNode) {
let x = node.x;
let z = node.z;
// Get hydrant type - skip underground, wall, and pond types
let hydrant_type = node
.tags
.get("fire_hydrant:type")
.map(|s| s.as_str())
.unwrap_or("pillar");
// Skip non-visible hydrant types
if matches!(hydrant_type, "underground" | "wall" | "pond") {
return;
}
// Simple hydrant: brick wall with redstone block on top
editor.set_block(BRICK_WALL, x, 1, z, None, None);
editor.set_block(REDSTONE_BLOCK, x, 2, z, None, None);
}

View File

@@ -0,0 +1,207 @@
//! Processing of historic elements.
//!
//! This module handles historic OSM elements including:
//! - `historic=memorial` - Memorials, monuments, and commemorative structures
use crate::block_definitions::*;
use crate::deterministic_rng::element_rng;
use crate::osm_parser::ProcessedNode;
use crate::world_editor::WorldEditor;
use rand::Rng;
/// Generate historic structures from node elements
pub fn generate_historic(editor: &mut WorldEditor, node: &ProcessedNode) {
// Skip if 'layer' or 'level' is negative in the tags
if let Some(layer) = node.tags.get("layer") {
if layer.parse::<i32>().unwrap_or(0) < 0 {
return;
}
}
if let Some(level) = node.tags.get("level") {
if level.parse::<i32>().unwrap_or(0) < 0 {
return;
}
}
if let Some(historic_type) = node.tags.get("historic") {
match historic_type.as_str() {
"memorial" => generate_memorial(editor, node),
"monument" => generate_monument(editor, node),
"wayside_cross" => generate_wayside_cross(editor, node),
_ => {}
}
}
}
/// Generate a memorial structure
///
/// Memorials come in many forms. We determine the type from the `memorial` tag:
/// - plaque: Simple wall-mounted or standing plaque
/// - statue: A statue on a pedestal
/// - sculpture: Artistic sculpture
/// - stone/stolperstein: Memorial stone
/// - bench: Memorial bench (already handled by amenity=bench typically)
/// - cross: Memorial cross
/// - obelisk: Tall pointed pillar
/// - stele: Upright stone slab
/// - bust: Bust on a pedestal
/// - Default: A general monument/pillar
fn generate_memorial(editor: &mut WorldEditor, node: &ProcessedNode) {
let x = node.x;
let z = node.z;
// Use deterministic RNG for consistent results
let mut rng = element_rng(node.id);
// Get memorial subtype
let memorial_type = node
.tags
.get("memorial")
.map(|s| s.as_str())
.unwrap_or("yes");
match memorial_type {
"plaque" => {
// Simple plaque on a small stand
editor.set_block(STONE_BRICKS, x, 1, z, None, None);
editor.set_block(STONE_BRICK_SLAB, x, 2, z, None, None);
}
"statue" | "sculpture" | "bust" => {
// Statue on a pedestal
editor.set_block(STONE_BRICKS, x, 1, z, None, None);
editor.set_block(CHISELED_STONE_BRICKS, x, 2, z, None, None);
// Use polished andesite for bronze/metal statue appearance
let statue_block = if rng.gen_bool(0.5) {
POLISHED_ANDESITE
} else {
POLISHED_DIORITE
};
editor.set_block(statue_block, x, 3, z, None, None);
editor.set_block(statue_block, x, 4, z, None, None);
editor.set_block(STONE_BRICK_WALL, x, 5, z, None, None);
}
"stone" | "stolperstein" => {
// Simple memorial stone embedded in ground
let stone_block = if memorial_type == "stolperstein" {
GOLD_BLOCK // Stolpersteine are brass/gold colored
} else {
STONE
};
editor.set_block(stone_block, x, 0, z, None, None);
}
"cross" | "war_memorial" => {
// Memorial cross
generate_cross(editor, x, z, 5);
}
"obelisk" => {
// Tall pointed pillar with fixed height
// Base layer at Y=1
for dx in -1..=1 {
for dz in -1..=1 {
editor.set_block(STONE_BRICKS, x + dx, 1, z + dz, None, None);
}
}
// Second base layer at Y=2
for dx in -1..=1 {
for dz in -1..=1 {
editor.set_block(STONE_BRICKS, x + dx, 2, z + dz, None, None);
}
}
// Stone brick slabs on the 4 corners at Y=3 (on top of corner blocks)
editor.set_block(STONE_BRICK_SLAB, x - 1, 3, z - 1, None, None);
editor.set_block(STONE_BRICK_SLAB, x + 1, 3, z - 1, None, None);
editor.set_block(STONE_BRICK_SLAB, x - 1, 3, z + 1, None, None);
editor.set_block(STONE_BRICK_SLAB, x + 1, 3, z + 1, None, None);
// Main shaft, fixed height of 4 blocks (Y=3 to Y=6)
for y in 3..=6 {
editor.set_block(SMOOTH_QUARTZ, x, y, z, None, None);
}
editor.set_block(STONE_BRICK_SLAB, x, 7, z, None, None);
}
"stele" => {
// Upright stone slab
// Base
editor.set_block(STONE_BRICKS, x, 1, z, None, None);
// Upright slab (using wall blocks for thin appearance)
for y in 2..=4 {
editor.set_block(STONE_BRICK_WALL, x, y, z, None, None);
}
editor.set_block(STONE_BRICK_SLAB, x, 5, z, None, None);
}
_ => {
// Default: simple stone pillar monument
editor.set_block(STONE_BRICKS, x, 1, z, None, None);
editor.set_block(STONE_BRICKS, x, 2, z, None, None);
editor.set_block(CHISELED_STONE_BRICKS, x, 3, z, None, None);
editor.set_block(STONE_BRICK_SLAB, x, 4, z, None, None);
}
}
}
/// Generate a monument (larger than memorial)
fn generate_monument(editor: &mut WorldEditor, node: &ProcessedNode) {
let x = node.x;
let z = node.z;
// Monuments are typically larger structures
let height = node
.tags
.get("height")
.and_then(|h| h.parse::<i32>().ok())
.unwrap_or(10)
.clamp(5, 20);
// Large base platform
for dx in -2..=2 {
for dz in -2..=2 {
editor.set_block(STONE_BRICKS, x + dx, 1, z + dz, None, None);
}
}
for dx in -1..=1 {
for dz in -1..=1 {
editor.set_block(STONE_BRICKS, x + dx, 2, z + dz, None, None);
}
}
// Main structure
for y in 3..height {
editor.set_block(POLISHED_ANDESITE, x, y, z, None, None);
}
// Decorative top
editor.set_block(CHISELED_STONE_BRICKS, x, height, z, None, None);
}
/// Generate a wayside cross
fn generate_wayside_cross(editor: &mut WorldEditor, node: &ProcessedNode) {
let x = node.x;
let z = node.z;
// Simple roadside cross
generate_cross(editor, x, z, 4);
}
/// Helper function to generate a cross structure
fn generate_cross(editor: &mut WorldEditor, x: i32, z: i32, height: i32) {
// Base
editor.set_block(STONE_BRICKS, x, 1, z, None, None);
// Vertical beam
for y in 2..=height {
editor.set_block(STONE_BRICK_WALL, x, y, z, None, None);
}
// Horizontal beam (cross arm) at approximately 2/3 height, but at least 2 and at most height-1
let arm_y = ((height * 2 + 2) / 3).clamp(2, height - 1);
// Only place horizontal arms if height allows for them (height >= 3)
if height >= 3 {
editor.set_block(STONE_BRICK_WALL, x - 1, arm_y, z, None, None);
editor.set_block(STONE_BRICK_WALL, x + 1, arm_y, z, None, None);
}
}

View File

@@ -1,14 +1,17 @@
pub mod advertising;
pub mod amenities;
pub mod barriers;
pub mod boundaries;
pub mod bridges;
pub mod buildings;
pub mod doors;
pub mod emergency;
pub mod highways;
pub mod historic;
pub mod landuse;
pub mod leisure;
pub mod man_made;
pub mod natural;
pub mod power;
pub mod railways;
pub mod subprocessor;
pub mod tourisms;

View File

@@ -0,0 +1,385 @@
//! Processing of power infrastructure elements.
//!
//! This module handles power-related OSM elements including:
//! - `power=tower` - Large electricity pylons
//! - `power=pole` - Smaller wooden/concrete poles
//! - `power=line` - Power lines connecting towers/poles
use crate::block_definitions::*;
use crate::bresenham::bresenham_line;
use crate::osm_parser::{ProcessedElement, ProcessedNode, ProcessedWay};
use crate::world_editor::WorldEditor;
/// Generate power infrastructure from way elements (power lines)
pub fn generate_power(editor: &mut WorldEditor, element: &ProcessedElement) {
// Skip if 'layer' or 'level' is negative in the tags
if let Some(layer) = element.tags().get("layer") {
if layer.parse::<i32>().unwrap_or(0) < 0 {
return;
}
}
if let Some(level) = element.tags().get("level") {
if level.parse::<i32>().unwrap_or(0) < 0 {
return;
}
}
// Skip underground power infrastructure
if element
.tags()
.get("location")
.map(|v| v == "underground" || v == "underwater")
.unwrap_or(false)
{
return;
}
if element
.tags()
.get("tunnel")
.map(|v| v == "yes")
.unwrap_or(false)
{
return;
}
if let Some(power_type) = element.tags().get("power") {
match power_type.as_str() {
"line" | "minor_line" => {
if let ProcessedElement::Way(way) = element {
generate_power_line(editor, way);
}
}
"tower" => generate_power_tower(editor, element),
"pole" => generate_power_pole(editor, element),
_ => {}
}
}
}
/// Generate power infrastructure from node elements
pub fn generate_power_nodes(editor: &mut WorldEditor, node: &ProcessedNode) {
// Skip if 'layer' or 'level' is negative in the tags
if let Some(layer) = node.tags.get("layer") {
if layer.parse::<i32>().unwrap_or(0) < 0 {
return;
}
}
if let Some(level) = node.tags.get("level") {
if level.parse::<i32>().unwrap_or(0) < 0 {
return;
}
}
// Skip underground power infrastructure
if node
.tags
.get("location")
.map(|v| v == "underground" || v == "underwater")
.unwrap_or(false)
{
return;
}
if node.tags.get("tunnel").map(|v| v == "yes").unwrap_or(false) {
return;
}
if let Some(power_type) = node.tags.get("power") {
match power_type.as_str() {
"tower" => generate_power_tower_from_node(editor, node),
"pole" => generate_power_pole_from_node(editor, node),
_ => {}
}
}
}
/// Generate a high-voltage transmission tower (pylon) from a ProcessedElement
fn generate_power_tower(editor: &mut WorldEditor, element: &ProcessedElement) {
let Some(first_node) = element.nodes().next() else {
return;
};
let height = element
.tags()
.get("height")
.and_then(|h| h.parse::<i32>().ok())
.unwrap_or(25)
.clamp(15, 40);
generate_power_tower_impl(editor, first_node.x, first_node.z, height);
}
/// Generate a high-voltage transmission tower (pylon) from a ProcessedNode
fn generate_power_tower_from_node(editor: &mut WorldEditor, node: &ProcessedNode) {
let height = node
.tags
.get("height")
.and_then(|h| h.parse::<i32>().ok())
.unwrap_or(25)
.clamp(15, 40);
generate_power_tower_impl(editor, node.x, node.z, height);
}
/// Generate a high-voltage transmission tower (pylon)
///
/// Creates a realistic lattice tower structure using iron bars and iron blocks.
/// The design is a tapered lattice tower with cross-bracing and insulators.
fn generate_power_tower_impl(editor: &mut WorldEditor, x: i32, z: i32, height: i32) {
// Tower design constants
let base_width = 3; // Half-width at base (so 7x7 footprint)
let top_width = 1; // Half-width at top (so 3x3)
let arm_height = height - 4; // Height where arms extend
let arm_length = 5; // How far arms extend horizontally
// Build the four corner legs with tapering
for y in 1..=height {
// Calculate taper: legs get closer together as we go up
let progress = y as f32 / height as f32;
let current_width = base_width - ((base_width - top_width) as f32 * progress) as i32;
// Four corner positions
let corners = [
(x - current_width, z - current_width),
(x + current_width, z - current_width),
(x - current_width, z + current_width),
(x + current_width, z + current_width),
];
for (cx, cz) in corners {
editor.set_block(IRON_BLOCK, cx, y, cz, None, None);
}
// Add horizontal cross-bracing every 5 blocks
if y % 5 == 0 && y < height - 2 {
// Connect corners horizontally
for dx in -current_width..=current_width {
editor.set_block(IRON_BLOCK, x + dx, y, z - current_width, None, None);
editor.set_block(IRON_BLOCK, x + dx, y, z + current_width, None, None);
}
for dz in -current_width..=current_width {
editor.set_block(IRON_BLOCK, x - current_width, y, z + dz, None, None);
editor.set_block(IRON_BLOCK, x + current_width, y, z + dz, None, None);
}
}
// Add diagonal bracing between cross-brace levels
if y % 5 >= 1 && y % 5 <= 4 && y > 1 && y < height - 2 {
let prev_width = base_width
- ((base_width - top_width) as f32 * ((y - 1) as f32 / height as f32)) as i32;
// Only add center vertical support if the width changed
if current_width != prev_width || y % 5 == 2 {
editor.set_block(IRON_BARS, x, y, z, None, None);
}
}
}
// Create the cross-arms at arm_height for holding power lines
// These extend outward in two directions (perpendicular to typical line direction)
for arm_offset in [-arm_length, arm_length] {
// Main arm beam (iron blocks for strength)
for dx in 0..=arm_length {
let arm_x = if arm_offset < 0 { x - dx } else { x + dx };
editor.set_block(IRON_BLOCK, arm_x, arm_height, z, None, None);
// Add second arm perpendicular
editor.set_block(
IRON_BLOCK,
x,
arm_height,
z + if arm_offset < 0 { -dx } else { dx },
None,
None,
);
}
// Insulators hanging from arm ends (end rods to simulate ceramic insulators)
let end_x = if arm_offset < 0 {
x - arm_length
} else {
x + arm_length
};
editor.set_block(END_ROD, end_x, arm_height - 1, z, None, None);
editor.set_block(END_ROD, x, arm_height - 1, z + arm_offset, None, None);
}
// Add a second, smaller arm set lower for additional circuits
let lower_arm_height = arm_height - 6;
if lower_arm_height > 5 {
let lower_arm_length = arm_length - 1;
for arm_offset in [-lower_arm_length, lower_arm_length] {
for dx in 0..=lower_arm_length {
let arm_x = if arm_offset < 0 { x - dx } else { x + dx };
editor.set_block(IRON_BLOCK, arm_x, lower_arm_height, z, None, None);
}
let end_x = if arm_offset < 0 {
x - lower_arm_length
} else {
x + lower_arm_length
};
editor.set_block(END_ROD, end_x, lower_arm_height - 1, z, None, None);
}
}
// Top finial/lightning rod
editor.set_block(IRON_BLOCK, x, height, z, None, None);
editor.set_block(LIGHTNING_ROD, x, height + 1, z, None, None);
// Concrete foundation at base
for dx in -3..=3 {
for dz in -3..=3 {
editor.set_block(GRAY_CONCRETE, x + dx, 0, z + dz, None, None);
}
}
}
/// Generate a wooden/concrete power pole from a ProcessedElement
fn generate_power_pole(editor: &mut WorldEditor, element: &ProcessedElement) {
let Some(first_node) = element.nodes().next() else {
return;
};
let height = element
.tags()
.get("height")
.and_then(|h| h.parse::<i32>().ok())
.unwrap_or(10)
.clamp(6, 15);
let pole_material = element
.tags()
.get("material")
.map(|m| m.as_str())
.unwrap_or("wood");
generate_power_pole_impl(editor, first_node.x, first_node.z, height, pole_material);
}
/// Generate a wooden/concrete power pole from a ProcessedNode
fn generate_power_pole_from_node(editor: &mut WorldEditor, node: &ProcessedNode) {
let height = node
.tags
.get("height")
.and_then(|h| h.parse::<i32>().ok())
.unwrap_or(10)
.clamp(6, 15);
let pole_material = node
.tags
.get("material")
.map(|m| m.as_str())
.unwrap_or("wood");
generate_power_pole_impl(editor, node.x, node.z, height, pole_material);
}
/// Generate a wooden/concrete power pole
///
/// Creates a simpler single-pole structure for lower voltage distribution lines.
fn generate_power_pole_impl(
editor: &mut WorldEditor,
x: i32,
z: i32,
height: i32,
pole_material: &str,
) {
let pole_block = match pole_material {
"concrete" => LIGHT_GRAY_CONCRETE,
"steel" | "metal" => IRON_BLOCK,
_ => OAK_LOG, // Default to wood
};
// Build the main pole
for y in 1..=height {
editor.set_block(pole_block, x, y, z, None, None);
}
// Cross-arm at top (perpendicular beam for wires)
let arm_length = 2;
for dx in -arm_length..=arm_length {
editor.set_block(OAK_FENCE, x + dx, height, z, None, None);
}
// Insulators at arm ends
editor.set_block(END_ROD, x - arm_length, height + 1, z, None, None);
editor.set_block(END_ROD, x + arm_length, height + 1, z, None, None);
editor.set_block(END_ROD, x, height + 1, z, None, None); // Center insulator
}
/// Generate power lines connecting towers/poles
///
/// Creates a catenary-like curve (simplified) between nodes to simulate
/// the natural sag of power cables.
fn generate_power_line(editor: &mut WorldEditor, way: &ProcessedWay) {
if way.nodes.len() < 2 {
return;
}
// Determine line height based on voltage (higher voltage = taller structures)
let base_height = way
.tags
.get("voltage")
.and_then(|v| v.parse::<i32>().ok())
.map(|voltage| {
if voltage >= 220000 {
22 // High voltage transmission
} else if voltage >= 110000 {
18
} else if voltage >= 33000 {
14
} else {
10 // Distribution lines
}
})
.unwrap_or(15);
// Process consecutive node pairs
for i in 1..way.nodes.len() {
let start = &way.nodes[i - 1];
let end = &way.nodes[i];
// Calculate distance between nodes
let dx = (end.x - start.x) as f64;
let dz = (end.z - start.z) as f64;
let distance = (dx * dx + dz * dz).sqrt();
// Calculate sag based on span length (longer spans = more sag)
let max_sag = (distance / 15.0).clamp(1.0, 6.0) as i32;
// Determine chain orientation based on line direction
// If the line runs more along X-axis, use CHAIN_X; if more along Z-axis, use CHAIN_Z
let chain_block = if dx.abs() >= dz.abs() {
CHAIN_X // Line runs primarily along X-axis
} else {
CHAIN_Z // Line runs primarily along Z-axis
};
// Generate points along the line using Bresenham
let line_points = bresenham_line(start.x, 0, start.z, end.x, 0, end.z);
for (idx, (lx, _, lz)) in line_points.iter().enumerate() {
// Calculate position along the span (0.0 to 1.0)
// Use len-1 as denominator so last point reaches t=1.0
let denom = (line_points.len().saturating_sub(1)).max(1) as f64;
let t = idx as f64 / denom;
// Catenary approximation: sag is maximum at center, zero at ends
// Using parabola: sag = 4 * max_sag * t * (1 - t)
let sag = (4.0 * max_sag as f64 * t * (1.0 - t)) as i32;
// Ensure wire doesn't go underground (minimum height of 3 blocks above ground)
let wire_y = (base_height - sag).max(3);
// Place the wire block (chain aligned with line direction)
editor.set_block(chain_block, *lx, wire_y, *lz, None, None);
// For high voltage lines, add parallel wires offset to sides
if base_height >= 18 {
// Three-phase power: 3 parallel lines
// Offset perpendicular to the line direction
if dx.abs() >= dz.abs() {
// Line runs along X, offset in Z
editor.set_block(chain_block, *lx, wire_y, *lz + 1, None, None);
editor.set_block(chain_block, *lx, wire_y, *lz - 1, None, None);
} else {
// Line runs along Z, offset in X
editor.set_block(chain_block, *lx + 1, wire_y, *lz, None, None);
editor.set_block(chain_block, *lx - 1, wire_y, *lz, None, None);
}
}
}
}
}

View File

@@ -11,13 +11,13 @@ use fnv::FnvHashMap;
use rayon::prelude::*;
use std::time::Duration;
/// A memory-efficient bitmap for storing building footprint coordinates.
/// A memory-efficient bitmap for storing coordinates.
///
/// Instead of storing each coordinate individually (~24 bytes per entry in a HashSet),
/// this uses 1 bit per coordinate in the world bounds, reducing memory usage by ~200x.
///
/// For a world of size W x H blocks, the bitmap uses only (W * H) / 8 bytes.
pub struct BuildingFootprintBitmap {
pub struct CoordinateBitmap {
/// The bitmap data, where each bit represents one (x, z) coordinate
bits: Vec<u8>,
/// Minimum x coordinate (offset for indexing)
@@ -27,12 +27,13 @@ pub struct BuildingFootprintBitmap {
/// Width of the world (max_x - min_x + 1)
width: usize,
/// Height of the world (max_z - min_z + 1)
#[allow(dead_code)]
height: usize,
/// Number of coordinates marked as building footprints
/// Number of coordinates marked
count: usize,
}
impl BuildingFootprintBitmap {
impl CoordinateBitmap {
/// Creates a new empty bitmap covering the given world bounds.
pub fn new(xzbbox: &XZBBox) -> Self {
let min_x = xzbbox.min_x();
@@ -44,7 +45,7 @@ impl BuildingFootprintBitmap {
// Calculate number of bytes needed (round up to nearest byte)
let total_bits = width
.checked_mul(height)
.expect("BuildingFootprintBitmap: world size too large (width * height overflowed)");
.expect("CoordinateBitmap: world size too large (width * height overflowed)");
let num_bytes = total_bits.div_ceil(8);
Self {
@@ -79,7 +80,7 @@ impl BuildingFootprintBitmap {
Some(local_z * self.width + local_x)
}
/// Sets a coordinate as part of a building footprint.
/// Sets a coordinate.
#[inline]
pub fn set(&mut self, x: i32, z: i32) {
if let Some(bit_index) = self.coord_to_index(x, z) {
@@ -96,7 +97,7 @@ impl BuildingFootprintBitmap {
}
}
/// Checks if a coordinate is part of a building footprint.
/// Checks if a coordinate is set.
#[inline]
pub fn contains(&self, x: i32, z: i32) -> bool {
if let Some(bit_index) = self.coord_to_index(x, z) {
@@ -111,12 +112,119 @@ impl BuildingFootprintBitmap {
/// Returns true if no coordinates are marked.
#[must_use]
#[allow(dead_code)] // Standard API method for collection-like types
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.count == 0
}
/// Returns the number of coordinates that are set.
#[inline]
#[allow(dead_code)]
pub fn count(&self) -> usize {
self.count
}
/// Counts how many coordinates from the given iterator are set in this bitmap.
#[inline]
#[allow(dead_code)]
pub fn count_contained<'a, I>(&self, coords: I) -> usize
where
I: Iterator<Item = &'a (i32, i32)>,
{
coords.filter(|(x, z)| self.contains(*x, *z)).count()
}
/// Counts the number of set bits in a rectangular range.
///
/// This is optimized to iterate row-by-row and use `count_ones()` on bytes
/// where possible, which is much faster than checking individual coordinates.
///
/// Returns `(urban_count, total_count)` for the given range.
#[inline]
#[allow(dead_code)]
pub fn count_in_range(&self, min_x: i32, min_z: i32, max_x: i32, max_z: i32) -> (usize, usize) {
let mut urban_count = 0usize;
let mut total_count = 0usize;
for z in min_z..=max_z {
// Calculate local z coordinate
let local_z = i64::from(z) - i64::from(self.min_z);
if local_z < 0 || local_z >= self.height as i64 {
// Row is out of bounds, still counts toward total
total_count += (i64::from(max_x) - i64::from(min_x) + 1) as usize;
continue;
}
let local_z = local_z as usize;
// Calculate x range in local coordinates
let local_min_x = (i64::from(min_x) - i64::from(self.min_x)).max(0) as usize;
let local_max_x =
((i64::from(max_x) - i64::from(self.min_x)) as usize).min(self.width - 1);
// Count out-of-bounds x coordinates toward total
let x_start_offset = (i64::from(self.min_x) - i64::from(min_x)).max(0) as usize;
let x_end_offset = (i64::from(max_x) - i64::from(self.min_x) - (self.width as i64 - 1))
.max(0) as usize;
total_count += x_start_offset + x_end_offset;
if local_min_x > local_max_x {
continue;
}
// Process this row
let row_start_bit = local_z * self.width + local_min_x;
let row_end_bit = local_z * self.width + local_max_x;
let num_bits = row_end_bit - row_start_bit + 1;
total_count += num_bits;
// Count set bits using byte-wise popcount where possible
let start_byte = row_start_bit / 8;
let end_byte = row_end_bit / 8;
let start_bit_in_byte = row_start_bit % 8;
let end_bit_in_byte = row_end_bit % 8;
if start_byte == end_byte {
// All bits are in the same byte
let byte = self.bits[start_byte];
// Create mask for bits from start_bit to end_bit (inclusive)
let num_bits_in_mask = end_bit_in_byte - start_bit_in_byte + 1;
let mask = if num_bits_in_mask >= 8 {
0xFFu8
} else {
((1u16 << num_bits_in_mask) - 1) as u8
};
let masked = (byte >> start_bit_in_byte) & mask;
urban_count += masked.count_ones() as usize;
} else {
// First partial byte
let first_byte = self.bits[start_byte];
let first_mask = !((1u8 << start_bit_in_byte) - 1); // bits from start_bit to 7
urban_count += (first_byte & first_mask).count_ones() as usize;
// Full bytes in between
for byte_idx in (start_byte + 1)..end_byte {
urban_count += self.bits[byte_idx].count_ones() as usize;
}
// Last partial byte
let last_byte = self.bits[end_byte];
// Handle case where end_bit_in_byte is 7 (would overflow 1u8 << 8)
let last_mask = if end_bit_in_byte >= 7 {
0xFFu8
} else {
(1u8 << (end_bit_in_byte + 1)) - 1
};
urban_count += (last_byte & last_mask).count_ones() as usize;
}
}
(urban_count, total_count)
}
}
/// Type alias for building footprint bitmap (for backwards compatibility).
pub type BuildingFootprintBitmap = CoordinateBitmap;
/// A cache of pre-computed flood fill results, keyed by element ID.
pub struct FloodFillCache {
/// Cached results: element_id -> filled coordinates
@@ -283,6 +391,61 @@ impl FloodFillCache {
footprints
}
/// Collects centroids of all buildings from the pre-computed cache.
///
/// This is used for urban ground detection - building clusters are identified
/// using their centroids, and a concave hull is computed around dense clusters
/// to determine where city ground (smooth stone) should be placed.
///
/// Returns a vector of (x, z) centroid coordinates for all buildings.
pub fn collect_building_centroids(&self, elements: &[ProcessedElement]) -> Vec<(i32, i32)> {
let mut centroids = Vec::new();
for element in elements {
match element {
ProcessedElement::Way(way) => {
if way.tags.contains_key("building") || way.tags.contains_key("building:part") {
if let Some(cached) = self.way_cache.get(&way.id) {
if let Some(centroid) = Self::compute_centroid(cached) {
centroids.push(centroid);
}
}
}
}
ProcessedElement::Relation(rel) => {
if rel.tags.contains_key("building") || rel.tags.contains_key("building:part") {
// For building relations, compute centroid from outer ways
let mut all_coords = Vec::new();
for member in &rel.members {
if member.role == ProcessedMemberRole::Outer {
if let Some(cached) = self.way_cache.get(&member.way.id) {
all_coords.extend(cached.iter().copied());
}
}
}
if let Some(centroid) = Self::compute_centroid(&all_coords) {
centroids.push(centroid);
}
}
}
_ => {}
}
}
centroids
}
/// Computes the centroid of a set of coordinates.
fn compute_centroid(coords: &[(i32, i32)]) -> Option<(i32, i32)> {
if coords.is_empty() {
return None;
}
let sum_x: i64 = coords.iter().map(|(x, _)| i64::from(*x)).sum();
let sum_z: i64 = coords.iter().map(|(_, z)| i64::from(*z)).sum();
let len = coords.len() as i64;
Some(((sum_x / len) as i32, (sum_z / len) as i32))
}
/// Removes a way's cached flood fill result, freeing memory.
///
/// Call this after processing an element to release its cached data.

View File

@@ -815,6 +815,7 @@ fn gui_start_generation(
interior_enabled: bool,
roof_enabled: bool,
fillground_enabled: bool,
city_boundaries_enabled: bool,
is_new_world: bool,
spawn_point: Option<(f64, f64)>,
telemetry_consent: bool,
@@ -1007,6 +1008,7 @@ fn gui_start_generation(
interior: interior_enabled,
roof: roof_enabled,
fillground: fillground_enabled,
city_boundaries: city_boundaries_enabled,
debug: false,
timeout: Some(std::time::Duration::from_secs(40)),
};

View File

@@ -417,6 +417,10 @@ button:hover {
margin: 15px 0;
}
#city-boundaries-toggle {
accent-color: #fecc44;
}
#telemetry-toggle {
accent-color: #fecc44;
}

11
src/gui/index.html vendored
View File

@@ -138,6 +138,17 @@
</div>
</div>
<!-- City Ground Toggle Button -->
<div class="settings-row">
<label for="city-boundaries-toggle">
<span data-localize="city_boundaries">City Ground</span>
<span class="tooltip-icon" data-tooltip="Detect urban areas and place smooth stone ground where cities are located.">?</span>
</label>
<div class="settings-control">
<input type="checkbox" id="city-boundaries-toggle" name="city-boundaries-toggle" checked>
</div>
</div>
<!-- World Scale Slider -->
<div class="settings-row">
<label for="scale-value-slider">

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

@@ -104,20 +104,21 @@ async function applyLocalization(localization) {
"button[data-localize='select_existing_world']": "select_existing_world",
"button[data-localize='generate_new_world']": "generate_new_world",
"h2[data-localize='customization_settings']": "customization_settings",
"label[data-localize='world_scale']": "world_scale",
"label[data-localize='custom_bounding_box']": "custom_bounding_box",
"span[data-localize='world_scale']": "world_scale",
"span[data-localize='custom_bounding_box']": "custom_bounding_box",
// DEPRECATED: Ground level localization removed
// "label[data-localize='ground_level']": "ground_level",
"label[data-localize='language']": "language",
"label[data-localize='generation_mode']": "generation_mode",
"span[data-localize='language']": "language",
"span[data-localize='generation_mode']": "generation_mode",
"option[data-localize='mode_geo_terrain']": "mode_geo_terrain",
"option[data-localize='mode_geo_only']": "mode_geo_only",
"option[data-localize='mode_terrain_only']": "mode_terrain_only",
"label[data-localize='terrain']": "terrain",
"label[data-localize='interior']": "interior",
"label[data-localize='roof']": "roof",
"label[data-localize='fillground']": "fillground",
"label[data-localize='map_theme']": "map_theme",
"span[data-localize='terrain']": "terrain",
"span[data-localize='interior']": "interior",
"span[data-localize='roof']": "roof",
"span[data-localize='fillground']": "fillground",
"span[data-localize='city_boundaries']": "city_boundaries",
"span[data-localize='map_theme']": "map_theme",
".footer-link": "footer_text",
"button[data-localize='license_and_credits']": "license_and_credits",
"h2[data-localize='license_and_credits']": "license_and_credits",
@@ -832,6 +833,7 @@ async function startGeneration() {
var interior = document.getElementById("interior-toggle").checked;
var roof = document.getElementById("roof-toggle").checked;
var fill_ground = document.getElementById("fillground-toggle").checked;
var city_boundaries = document.getElementById("city-boundaries-toggle").checked;
var scale = parseFloat(document.getElementById("scale-value-slider").value);
// var ground_level = parseInt(document.getElementById("ground-level").value, 10);
// DEPRECATED: Ground level input removed from UI
@@ -854,6 +856,7 @@ async function startGeneration() {
interiorEnabled: interior,
roofEnabled: roof,
fillgroundEnabled: fill_ground,
cityBoundariesEnabled: city_boundaries,
isNewWorld: isNewWorld,
spawnPoint: spawnPoint,
telemetryConsent: telemetryConsent || false,

View File

@@ -42,5 +42,6 @@
"interior": "توليد الداخلية",
"roof": "توليد السقف",
"fillground": "ملء الأرض",
"city_boundaries": "أرضية المدينة",
"bedrock_use_java": "استخدم Java لاختيار العوالم"
}

View File

@@ -42,5 +42,6 @@
"interior": "Innenraum Generierung",
"roof": "Dach Generierung",
"fillground": "Boden füllen",
"city_boundaries": "Stadtboden",
"bedrock_use_java": "Java für Weltauswahl nutzen"
}

View File

@@ -42,5 +42,6 @@
"interior": "Interior Generation",
"roof": "Roof Generation",
"fillground": "Fill Ground",
"city_boundaries": "City Ground",
"bedrock_use_java": "Use Java to select worlds"
}

View File

@@ -42,5 +42,6 @@
"interior": "Generación Interior",
"roof": "Generación de Tejado",
"fillground": "Rellenar Suelo",
"city_boundaries": "Suelo Urbano",
"bedrock_use_java": "Usa Java para elegir mundos"
}

View File

@@ -42,5 +42,6 @@
"interior": "Sisätilan luonti",
"roof": "Katon luonti",
"fillground": "Täytä maa",
"city_boundaries": "Kaupungin maa",
"bedrock_use_java": "Käytä Javaa maailmojen valintaan"
}

View File

@@ -42,5 +42,6 @@
"interior": "Génération d'intérieur",
"roof": "Génération de toit",
"fillground": "Remplir le sol",
"city_boundaries": "Sol urbain",
"bedrock_use_java": "Utilisez Java pour les mondes"
}

View File

@@ -42,5 +42,6 @@
"interior": "Belső generálás",
"roof": "Tető generálás",
"fillground": "Talaj feltöltése",
"city_boundaries": "Városi talaj",
"bedrock_use_java": "Java világválasztáshoz"
}

View File

@@ -42,5 +42,6 @@
"interior": "내부 생성",
"roof": "지붕 생성",
"fillground": "지면 채우기",
"city_boundaries": "도시 지면",
"bedrock_use_java": "Java로 세계 선택"
}

View File

@@ -42,5 +42,6 @@
"interior": "Interjero generavimas",
"roof": "Stogo generavimas",
"fillground": "Užpildyti pagrindą",
"city_boundaries": "Miesto žemė",
"bedrock_use_java": "Naudok Java pasauliams"
}

View File

@@ -42,5 +42,6 @@
"interior": "Interjera ģenerēšana",
"roof": "Jumta ģenerēšana",
"fillground": "Aizpildīt zemi",
"city_boundaries": "Pilsētas zeme",
"bedrock_use_java": "Izmanto Java pasaulēm"
}

View File

@@ -42,5 +42,6 @@
"interior": "Generowanie wnętrza",
"roof": "Generowanie dachu",
"fillground": "Wypełnij podłoże",
"city_boundaries": "Podłoże miejskie",
"bedrock_use_java": "Użyj Java do wyboru światów"
}

View File

@@ -42,5 +42,6 @@
"interior": "Генерация Интерьера",
"roof": "Генерация Крыши",
"fillground": "Заполнить Землю",
"city_boundaries": "Городской грунт",
"bedrock_use_java": "Используйте Java для миров"
}

View File

@@ -42,5 +42,6 @@
"interior": "Interiörgenerering",
"roof": "Takgenerering",
"fillground": "Fyll mark",
"city_boundaries": "Stadsmark",
"bedrock_use_java": "Använd Java för världar"
}

View File

@@ -42,5 +42,6 @@
"interior": "Генерація інтер'єру",
"roof": "Генерація даху",
"fillground": "Заповнити землю",
"city_boundaries": "Міська земля",
"bedrock_use_java": "Використовуй Java для світів"
}

View File

@@ -42,5 +42,6 @@
"interior": "内部生成",
"roof": "屋顶生成",
"fillground": "填充地面",
"city_boundaries": "城市地面",
"bedrock_use_java": "使用Java选择世界"
}

View File

@@ -25,6 +25,7 @@ mod retrieve_data;
mod telemetry;
#[cfg(test)]
mod test_utilities;
mod urban_ground;
mod version_check;
mod world_editor;

View File

@@ -124,6 +124,7 @@ pub fn fetch_data_from_overpass(
r#"[out:json][timeout:360][bbox:{},{},{},{}];
(
nwr["building"];
nwr["building:part"];
nwr["highway"];
nwr["landuse"];
nwr["natural"];
@@ -134,10 +135,17 @@ pub fn fetch_data_from_overpass(
nwr["tourism"];
nwr["bridge"];
nwr["railway"];
nwr["roller_coaster"];
nwr["barrier"];
nwr["entrance"];
nwr["door"];
nwr["boundary"];
nwr["power"];
nwr["historic"];
nwr["emergency"];
nwr["advertising"];
nwr["man_made"];
nwr["aeroway"];
way;
)->.relsinbbox;
(

848
src/urban_ground.rs Normal file
View File

@@ -0,0 +1,848 @@
//! Urban ground detection and generation based on building clusters.
//!
//! This module computes urban areas by analyzing building density and clustering,
//! then generates appropriate ground blocks (smooth stone) for those areas.
//!
//! # Algorithm Overview
//!
//! 1. **Grid-based density analysis**: Divide the world into cells and count buildings per cell
//! 2. **Connected component detection**: Find clusters of dense cells using flood fill
//! 3. **Cluster filtering**: Only keep clusters with enough buildings to be considered "urban"
//! 4. **Concave hull computation**: Compute a tight-fitting boundary around each cluster
//! 5. **Ground filling**: Fill the hull area with stone blocks
//!
//! This approach handles various scenarios:
//! - Full city coverage: Large connected cluster
//! - Multiple cities: Separate clusters, each gets its own hull
//! - Rural areas: No clusters meet threshold, no stone placed
//! - Isolated buildings: Don't meet cluster threshold, remain on grass
use crate::coordinate_system::cartesian::XZBBox;
use crate::floodfill::flood_fill_area;
use geo::{ConcaveHull, ConvexHull, MultiPoint, Point, Polygon, Simplify};
use std::collections::{HashMap, HashSet, VecDeque};
use std::time::Duration;
/// Configuration for urban ground detection.
///
/// These parameters control how building clusters are identified and
/// how the urban ground boundary is computed.
#[derive(Debug, Clone)]
pub struct UrbanGroundConfig {
/// Grid cell size for density analysis (in blocks).
/// Smaller = more precise but slower. Default: 64 blocks (4 chunks).
pub cell_size: i32,
/// Minimum buildings per cell to consider it potentially urban.
/// Cells below this threshold are ignored. Default: 1.
pub min_buildings_per_cell: usize,
/// Minimum total buildings in a connected cluster to be considered urban.
/// Small clusters (villages, isolated buildings) won't get stone ground. Default: 5.
pub min_buildings_for_cluster: usize,
/// Concavity parameter for hull computation (used in legacy hull-based method).
/// Lower = tighter fit to buildings (more concave), Higher = smoother (more convex).
/// Range: 1.0 (very tight) to 10.0 (almost convex). Default: 2.0.
pub concavity: f64,
/// Whether to expand the hull slightly beyond building boundaries (used in legacy method).
/// This creates a small buffer zone around the urban area. Default: true.
pub expand_hull: bool,
/// Base number of cells to expand the urban region.
/// This helps fill small gaps between buildings. Adaptive expansion may increase this.
/// Default: 2.
pub cell_expansion: i32,
}
impl Default for UrbanGroundConfig {
fn default() -> Self {
Self {
cell_size: 64, // Smaller cells for better granularity (4 chunks instead of 6)
min_buildings_per_cell: 1,
min_buildings_for_cluster: 5,
concavity: 2.0,
expand_hull: true,
cell_expansion: 2, // Larger expansion to connect spread-out buildings
}
}
}
/// Represents a detected urban cluster with its buildings and computed boundary.
#[derive(Debug)]
#[allow(dead_code)]
pub struct UrbanCluster {
/// Grid cells that belong to this cluster
cells: Vec<(i32, i32)>,
/// Building centroids within this cluster
building_centroids: Vec<(i32, i32)>,
/// Total number of buildings in the cluster
building_count: usize,
}
/// A compact lookup structure for checking if a coordinate is in an urban area.
///
/// Instead of storing millions of individual coordinates, this stores only
/// the cell indices (thousands) and performs O(1) lookups. This reduces
/// memory usage by ~4000x compared to storing all coordinates.
///
/// # Memory Usage
/// - 7.8 km² area: ~17K cells × 16 bytes = ~270 KB (vs ~560 MB for coordinates)
/// - 100 km² area: ~220K cells × 16 bytes = ~3.5 MB (vs ~7 GB for coordinates)
#[derive(Debug, Clone)]
pub struct UrbanGroundLookup {
/// Set of cell indices (cx, cz) that are urban
urban_cells: HashSet<(i32, i32)>,
/// Cell size used for coordinate-to-cell conversion
cell_size: i32,
/// Bounding box origin for coordinate conversion
bbox_min_x: i32,
bbox_min_z: i32,
}
impl UrbanGroundLookup {
/// Creates an empty lookup (no urban areas).
pub fn empty() -> Self {
Self {
urban_cells: HashSet::new(),
cell_size: 64,
bbox_min_x: 0,
bbox_min_z: 0,
}
}
/// Returns true if the given world coordinate is in an urban area.
#[inline]
pub fn is_urban(&self, x: i32, z: i32) -> bool {
if self.urban_cells.is_empty() {
return false;
}
let cx = (x - self.bbox_min_x) / self.cell_size;
let cz = (z - self.bbox_min_z) / self.cell_size;
self.urban_cells.contains(&(cx, cz))
}
/// Returns the number of urban cells.
#[allow(dead_code)]
pub fn cell_count(&self) -> usize {
self.urban_cells.len()
}
/// Returns true if there are no urban areas.
pub fn is_empty(&self) -> bool {
self.urban_cells.is_empty()
}
}
/// Computes urban ground areas from building locations.
pub struct UrbanGroundComputer {
config: UrbanGroundConfig,
building_centroids: Vec<(i32, i32)>,
xzbbox: XZBBox,
}
impl UrbanGroundComputer {
/// Creates a new urban ground computer with the given world bounds and configuration.
pub fn new(xzbbox: XZBBox, config: UrbanGroundConfig) -> Self {
Self {
config,
building_centroids: Vec::new(),
xzbbox,
}
}
/// Creates a new urban ground computer with default configuration.
pub fn with_defaults(xzbbox: XZBBox) -> Self {
Self::new(xzbbox, UrbanGroundConfig::default())
}
/// Adds a building centroid to be considered for urban area detection.
#[inline]
pub fn add_building_centroid(&mut self, x: i32, z: i32) {
// Only add if within bounds
if x >= self.xzbbox.min_x()
&& x <= self.xzbbox.max_x()
&& z >= self.xzbbox.min_z()
&& z <= self.xzbbox.max_z()
{
self.building_centroids.push((x, z));
}
}
/// Adds multiple building centroids from an iterator.
pub fn add_building_centroids<I>(&mut self, centroids: I)
where
I: IntoIterator<Item = (i32, i32)>,
{
for (x, z) in centroids {
self.add_building_centroid(x, z);
}
}
/// Returns the number of buildings added.
#[allow(dead_code)]
pub fn building_count(&self) -> usize {
self.building_centroids.len()
}
/// Computes all urban ground coordinates.
///
/// Returns a list of (x, z) coordinates that should have stone ground.
/// The coordinates are clipped to the world bounding box.
///
/// Performance: Uses cell-based filling for O(cells) complexity instead of
/// flood-filling complex hulls which would be O(area). For a city with 1000
/// buildings in 100 cells, this is ~100x faster than flood fill.
///
/// NOTE: For better performance and memory usage, prefer `compute_lookup()`.
#[allow(dead_code)]
pub fn compute(&self, _timeout: Option<&Duration>) -> Vec<(i32, i32)> {
// Not enough buildings for any urban area
if self.building_centroids.len() < self.config.min_buildings_for_cluster {
return Vec::new();
}
// Step 1: Create density grid (cell -> buildings in that cell)
let grid = self.create_density_grid();
// Step 2: Find connected urban regions and get their expanded cells
let clusters = self.find_urban_clusters(&grid);
if clusters.is_empty() {
return Vec::new();
}
// Step 3: Fill cells directly instead of using expensive flood fill on hulls
// This is much faster: O(cells × cell_size²) vs O(hull_area) for flood fill
let mut all_coords = Vec::new();
for cluster in clusters {
let coords = self.fill_cluster_cells(&cluster);
all_coords.extend(coords);
}
all_coords
}
/// Computes urban ground and returns a compact lookup structure.
///
/// This is the preferred method for production use. Instead of returning
/// millions of coordinates (high memory), it returns a lookup structure
/// that stores only cell indices (~4000x less memory) and provides O(1)
/// coordinate lookups.
///
/// # Memory Comparison
/// - `compute()`: ~560 MB for 7.8 km² area
/// - `compute_lookup()`: ~270 KB for same area
pub fn compute_lookup(&self) -> UrbanGroundLookup {
// Not enough buildings for any urban area
if self.building_centroids.len() < self.config.min_buildings_for_cluster {
return UrbanGroundLookup::empty();
}
// Step 1: Create density grid (cell -> buildings in that cell)
let grid = self.create_density_grid();
// Step 2: Find connected urban regions and get their expanded cells
let clusters = self.find_urban_clusters(&grid);
if clusters.is_empty() {
return UrbanGroundLookup::empty();
}
// Step 3: Collect all expanded cells from all clusters into a HashSet
let mut urban_cells = HashSet::new();
for cluster in clusters {
urban_cells.extend(cluster.cells.iter().copied());
}
UrbanGroundLookup {
urban_cells,
cell_size: self.config.cell_size,
bbox_min_x: self.xzbbox.min_x(),
bbox_min_z: self.xzbbox.min_z(),
}
}
/// Fills all cells in a cluster directly, returning coordinates.
/// This is much faster than computing a hull and flood-filling it.
fn fill_cluster_cells(&self, cluster: &UrbanCluster) -> Vec<(i32, i32)> {
let mut coords = Vec::new();
let cell_size = self.config.cell_size;
// Pre-calculate bounds once
let bbox_min_x = self.xzbbox.min_x();
let bbox_max_x = self.xzbbox.max_x();
let bbox_min_z = self.xzbbox.min_z();
let bbox_max_z = self.xzbbox.max_z();
for &(cx, cz) in &cluster.cells {
// Calculate cell bounds in world coordinates
let cell_min_x = (bbox_min_x + cx * cell_size).max(bbox_min_x);
let cell_max_x = (bbox_min_x + (cx + 1) * cell_size - 1).min(bbox_max_x);
let cell_min_z = (bbox_min_z + cz * cell_size).max(bbox_min_z);
let cell_max_z = (bbox_min_z + (cz + 1) * cell_size - 1).min(bbox_max_z);
// Skip if cell is entirely outside bbox
if cell_min_x > bbox_max_x
|| cell_max_x < bbox_min_x
|| cell_min_z > bbox_max_z
|| cell_max_z < bbox_min_z
{
continue;
}
// Fill all coordinates in this cell
for x in cell_min_x..=cell_max_x {
for z in cell_min_z..=cell_max_z {
coords.push((x, z));
}
}
}
coords
}
/// Creates a density grid mapping cell coordinates to buildings in that cell.
fn create_density_grid(&self) -> HashMap<(i32, i32), Vec<(i32, i32)>> {
let mut grid: HashMap<(i32, i32), Vec<(i32, i32)>> = HashMap::new();
for &(x, z) in &self.building_centroids {
let cell_x = (x - self.xzbbox.min_x()) / self.config.cell_size;
let cell_z = (z - self.xzbbox.min_z()) / self.config.cell_size;
grid.entry((cell_x, cell_z)).or_default().push((x, z));
}
grid
}
/// Finds connected clusters of urban cells.
fn find_urban_clusters(
&self,
grid: &HashMap<(i32, i32), Vec<(i32, i32)>>,
) -> Vec<UrbanCluster> {
// Step 1: Identify cells that meet minimum density threshold
let dense_cells: HashSet<(i32, i32)> = grid
.iter()
.filter(|(_, buildings)| buildings.len() >= self.config.min_buildings_per_cell)
.map(|(&cell, _)| cell)
.collect();
if dense_cells.is_empty() {
return Vec::new();
}
// Step 2: Calculate adaptive expansion based on building density
// For spread-out cities, we need more expansion to connect buildings
let adaptive_expansion = self.calculate_adaptive_expansion(&dense_cells, grid);
// Step 3: Expand dense cells to connect nearby clusters
let expanded_cells = self.expand_cells_adaptive(&dense_cells, adaptive_expansion);
// Step 4: Find connected components using flood fill
let mut visited = HashSet::new();
let mut clusters = Vec::new();
for &cell in &expanded_cells {
if visited.contains(&cell) {
continue;
}
// BFS to find connected component
let mut component_cells = Vec::new();
let mut queue = VecDeque::new();
queue.push_back(cell);
visited.insert(cell);
while let Some(current) = queue.pop_front() {
component_cells.push(current);
// Check 8-connected neighbors (including diagonals for better connectivity)
for dz in -1..=1 {
for dx in -1..=1 {
if dx == 0 && dz == 0 {
continue;
}
let neighbor = (current.0 + dx, current.1 + dz);
if expanded_cells.contains(&neighbor) && !visited.contains(&neighbor) {
visited.insert(neighbor);
queue.push_back(neighbor);
}
}
}
}
// Collect buildings from the original dense cells only (not expanded empty cells)
let mut cluster_buildings = Vec::new();
for &cell in &component_cells {
if let Some(buildings) = grid.get(&cell) {
cluster_buildings.extend(buildings.iter().copied());
}
}
let building_count = cluster_buildings.len();
// Only keep clusters with enough buildings
if building_count >= self.config.min_buildings_for_cluster {
clusters.push(UrbanCluster {
cells: component_cells,
building_centroids: cluster_buildings,
building_count,
});
}
}
clusters
}
/// Calculates adaptive expansion based on building density.
///
/// For spread-out cities (low density), we need more expansion to connect
/// buildings that are farther apart. For dense cities, less expansion is needed.
fn calculate_adaptive_expansion(
&self,
dense_cells: &HashSet<(i32, i32)>,
grid: &HashMap<(i32, i32), Vec<(i32, i32)>>,
) -> i32 {
if dense_cells.is_empty() {
return self.config.cell_expansion;
}
// Calculate total buildings and average per occupied cell
let total_buildings: usize = dense_cells
.iter()
.filter_map(|cell| grid.get(cell))
.map(|buildings| buildings.len())
.sum();
let avg_buildings_per_cell = total_buildings as f64 / dense_cells.len() as f64;
// Calculate the "spread" of cells - how far apart are occupied cells?
// Find bounding box of occupied cells
if dense_cells.len() < 2 {
return self.config.cell_expansion;
}
let min_x = dense_cells.iter().map(|(x, _)| x).min().unwrap();
let max_x = dense_cells.iter().map(|(x, _)| x).max().unwrap();
let min_z = dense_cells.iter().map(|(_, z)| z).min().unwrap();
let max_z = dense_cells.iter().map(|(_, z)| z).max().unwrap();
let grid_span_x = (max_x - min_x + 1) as f64;
let grid_span_z = (max_z - min_z + 1) as f64;
let total_possible_cells = grid_span_x * grid_span_z;
// Cell occupancy ratio: what fraction of the bounding box has buildings?
let occupancy = dense_cells.len() as f64 / total_possible_cells;
// Adaptive expansion logic:
// - High density (many buildings per cell) AND high occupancy = dense city, use base expansion
// - Low density OR low occupancy = spread-out city, need more expansion
let base_expansion = self.config.cell_expansion;
// Scale factor: lower density = higher factor
// avg_buildings_per_cell < 2 → spread out
// occupancy < 0.3 → sparse grid with gaps
let density_factor = if avg_buildings_per_cell < 3.0 {
1.5
} else {
1.0
};
let occupancy_factor = if occupancy < 0.4 {
1.5
} else if occupancy < 0.6 {
1.25
} else {
1.0
};
let adaptive = (base_expansion as f64 * density_factor * occupancy_factor).ceil() as i32;
// Cap at reasonable maximum (4 cells = 256 blocks with 64-block cells)
adaptive.min(4).max(base_expansion)
}
/// Expands the set of cells by adding neighbors within expansion distance.
fn expand_cells_adaptive(
&self,
cells: &HashSet<(i32, i32)>,
expansion: i32,
) -> HashSet<(i32, i32)> {
if expansion <= 0 {
return cells.clone();
}
let mut expanded = cells.clone();
for &(cx, cz) in cells {
for dz in -expansion..=expansion {
for dx in -expansion..=expansion {
expanded.insert((cx + dx, cz + dz));
}
}
}
expanded
}
/// Expands the set of cells by adding neighbors within expansion distance.
#[allow(dead_code)]
fn expand_cells(&self, cells: &HashSet<(i32, i32)>) -> HashSet<(i32, i32)> {
self.expand_cells_adaptive(cells, self.config.cell_expansion)
}
/// Computes ground coordinates for a single urban cluster.
///
/// NOTE: This hull-based method is kept for reference but not used in production.
/// The cell-based `fill_cluster_cells` method is much faster.
#[allow(dead_code)]
fn compute_cluster_ground(
&self,
cluster: &UrbanCluster,
grid: &HashMap<(i32, i32), Vec<(i32, i32)>>,
timeout: Option<&Duration>,
) -> Vec<(i32, i32)> {
// Need at least 3 points for a hull
if cluster.building_centroids.len() < 3 {
return Vec::new();
}
// Collect points for hull computation
// Include building centroids plus cell corner points for better coverage
let mut hull_points: Vec<(f64, f64)> = cluster
.building_centroids
.iter()
.map(|&(x, z)| (x as f64, z as f64))
.collect();
// Add cell boundary points if expand_hull is enabled
// This ensures the hull extends slightly beyond buildings
if self.config.expand_hull {
for &(cx, cz) in &cluster.cells {
// Only add corners for cells that actually have buildings
if grid.get(&(cx, cz)).map(|b| !b.is_empty()).unwrap_or(false) {
let base_x = (self.xzbbox.min_x() + cx * self.config.cell_size) as f64;
let base_z = (self.xzbbox.min_z() + cz * self.config.cell_size) as f64;
let size = self.config.cell_size as f64;
// Add cell corners with small padding
let pad = size * 0.1; // 10% padding
hull_points.push((base_x - pad, base_z - pad));
hull_points.push((base_x + size + pad, base_z - pad));
hull_points.push((base_x - pad, base_z + size + pad));
hull_points.push((base_x + size + pad, base_z + size + pad));
}
}
}
// Convert to geo MultiPoint
let multi_point: MultiPoint<f64> =
hull_points.iter().map(|&(x, z)| Point::new(x, z)).collect();
// Compute hull based on point count
let hull: Polygon<f64> = if hull_points.len() < 10 {
// Too few points for concave hull, use convex
multi_point.convex_hull()
} else {
// Use concave hull for better fit
multi_point.concave_hull(self.config.concavity)
};
// Simplify the hull to reduce vertex count (improves flood fill performance)
let hull = hull.simplify(2.0);
// Convert hull to integer coordinates for flood fill
self.fill_hull_polygon(&hull, timeout)
}
/// Fills a hull polygon and returns all interior coordinates.
///
/// NOTE: This method is kept for reference but not used in production.
/// The cell-based approach is much faster.
#[allow(dead_code)]
fn fill_hull_polygon(
&self,
polygon: &Polygon<f64>,
timeout: Option<&Duration>,
) -> Vec<(i32, i32)> {
// Convert polygon exterior to integer coordinates
let exterior: Vec<(i32, i32)> = polygon
.exterior()
.coords()
.map(|c| (c.x.round() as i32, c.y.round() as i32))
.collect();
if exterior.len() < 3 {
return Vec::new();
}
// Remove duplicate consecutive points (can cause flood fill issues)
let mut clean_exterior = Vec::with_capacity(exterior.len());
for point in exterior {
if clean_exterior.last() != Some(&point) {
clean_exterior.push(point);
}
}
// Ensure the polygon is closed
if clean_exterior.first() != clean_exterior.last() && !clean_exterior.is_empty() {
clean_exterior.push(clean_exterior[0]);
}
if clean_exterior.len() < 4 {
// Need at least 3 unique points + closing point
return Vec::new();
}
// Use existing flood fill, clipping to bbox
let filled = flood_fill_area(&clean_exterior, timeout);
// Filter to only include points within world bounds
filled
.into_iter()
.filter(|&(x, z)| {
x >= self.xzbbox.min_x()
&& x <= self.xzbbox.max_x()
&& z >= self.xzbbox.min_z()
&& z <= self.xzbbox.max_z()
})
.collect()
}
}
/// Computes the centroid of a set of coordinates.
///
/// Returns None if the slice is empty.
#[inline]
#[allow(dead_code)]
pub fn compute_centroid(coords: &[(i32, i32)]) -> Option<(i32, i32)> {
if coords.is_empty() {
return None;
}
let sum_x: i64 = coords.iter().map(|(x, _)| i64::from(*x)).sum();
let sum_z: i64 = coords.iter().map(|(_, z)| i64::from(*z)).sum();
let len = coords.len() as i64;
Some(((sum_x / len) as i32, (sum_z / len) as i32))
}
/// Convenience function to compute urban ground from building centroids.
///
/// NOTE: This function is kept for backward compatibility and tests.
/// For production use, prefer `compute_urban_ground_lookup` which uses
/// ~4000x less memory.
#[allow(dead_code)]
pub fn compute_urban_ground(
building_centroids: Vec<(i32, i32)>,
xzbbox: &XZBBox,
timeout: Option<&Duration>,
) -> Vec<(i32, i32)> {
let mut computer = UrbanGroundComputer::with_defaults(xzbbox.clone());
computer.add_building_centroids(building_centroids);
computer.compute(timeout)
}
/// Computes urban ground and returns a compact lookup structure.
///
/// This is the preferred entry point for production use. Returns a lookup
/// structure that uses ~270 KB instead of ~560 MB for a typical city area.
pub fn compute_urban_ground_lookup(
building_centroids: Vec<(i32, i32)>,
xzbbox: &XZBBox,
) -> UrbanGroundLookup {
let mut computer = UrbanGroundComputer::with_defaults(xzbbox.clone());
computer.add_building_centroids(building_centroids);
computer.compute_lookup()
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_bbox() -> XZBBox {
XZBBox::rect_from_xz_lengths(1000.0, 1000.0).unwrap()
}
#[test]
fn test_no_buildings() {
let computer = UrbanGroundComputer::with_defaults(create_test_bbox());
let result = computer.compute(None);
assert!(result.is_empty());
}
#[test]
fn test_few_scattered_buildings() {
let mut computer = UrbanGroundComputer::with_defaults(create_test_bbox());
// Add a few scattered buildings (not enough for a cluster)
computer.add_building_centroid(100, 100);
computer.add_building_centroid(500, 500);
computer.add_building_centroid(900, 900);
let result = computer.compute(None);
assert!(
result.is_empty(),
"Scattered buildings should not form urban area"
);
}
#[test]
fn test_dense_cluster() {
let mut computer = UrbanGroundComputer::with_defaults(create_test_bbox());
// Add a dense cluster of buildings
for i in 0..30 {
for j in 0..30 {
if (i + j) % 3 == 0 {
// Add building every 3rd position
computer.add_building_centroid(100 + i * 10, 100 + j * 10);
}
}
}
let result = computer.compute(None);
assert!(
!result.is_empty(),
"Dense cluster should produce urban area"
);
}
#[test]
fn test_compute_centroid() {
let coords = vec![(0, 0), (10, 0), (10, 10), (0, 10)];
let centroid = compute_centroid(&coords);
assert_eq!(centroid, Some((5, 5)));
}
#[test]
fn test_compute_centroid_empty() {
let coords: Vec<(i32, i32)> = vec![];
let centroid = compute_centroid(&coords);
assert_eq!(centroid, None);
}
#[test]
fn test_spread_out_buildings() {
// Simulate a spread-out city like Erding where buildings are farther apart
// This should still be detected as urban due to adaptive expansion
let mut computer = UrbanGroundComputer::with_defaults(create_test_bbox());
// Add buildings spread across a larger area with gaps
// Buildings are ~100-150 blocks apart (would fail with small expansion)
let building_positions = [
(100, 100),
(250, 100),
(400, 100),
(100, 250),
(250, 250),
(400, 250),
(100, 400),
(250, 400),
(400, 400),
// Add a few more to ensure cluster threshold is met
(175, 175),
(325, 175),
(175, 325),
(325, 325),
];
for (x, z) in building_positions {
computer.add_building_centroid(x, z);
}
let result = computer.compute(None);
assert!(
!result.is_empty(),
"Spread-out buildings should still form urban area with adaptive expansion"
);
}
#[test]
fn test_adaptive_expansion_calculated() {
let bbox = create_test_bbox();
let computer = UrbanGroundComputer::with_defaults(bbox);
// Create a sparse grid with low occupancy
let mut dense_cells = HashSet::new();
// Only 4 cells in a 10x10 potential grid = 4% occupancy
dense_cells.insert((0, 0));
dense_cells.insert((5, 0));
dense_cells.insert((0, 5));
dense_cells.insert((5, 5));
let mut grid = HashMap::new();
// Only 1 building per cell (low density)
grid.insert((0, 0), vec![(10, 10)]);
grid.insert((5, 0), vec![(330, 10)]);
grid.insert((0, 5), vec![(10, 330)]);
grid.insert((5, 5), vec![(330, 330)]);
let expansion = computer.calculate_adaptive_expansion(&dense_cells, &grid);
// Should be higher than base (2) due to low occupancy and density
assert!(
expansion > 2,
"Sparse grid should trigger higher expansion, got {}",
expansion
);
}
#[test]
fn test_lookup_empty() {
let lookup = UrbanGroundLookup::empty();
assert!(lookup.is_empty());
assert!(!lookup.is_urban(100, 100));
assert_eq!(lookup.cell_count(), 0);
}
#[test]
fn test_lookup_membership() {
let mut computer = UrbanGroundComputer::with_defaults(create_test_bbox());
// Create a dense cluster of buildings
for x in 0..10 {
for z in 0..10 {
computer.add_building_centroid(100 + x * 10, 100 + z * 10);
}
}
let lookup = computer.compute_lookup();
assert!(!lookup.is_empty());
// Points inside the cluster should be urban
assert!(
lookup.is_urban(150, 150),
"Center of cluster should be urban"
);
// Points far outside the cluster should not be urban
assert!(
!lookup.is_urban(900, 900),
"Point far from cluster should not be urban"
);
}
#[test]
fn test_lookup_vs_compute_consistency() {
let mut computer = UrbanGroundComputer::with_defaults(create_test_bbox());
// Create a medium-sized cluster
for x in 0..5 {
for z in 0..5 {
computer.add_building_centroid(200 + x * 20, 200 + z * 20);
}
}
let coords = computer.compute(None);
let lookup = computer.compute_lookup();
// Every coordinate from compute() should be marked urban in lookup
for (x, z) in &coords {
assert!(
lookup.is_urban(*x, *z),
"Coordinate ({}, {}) should be urban in lookup",
x,
z
);
}
}
}