Compare commits

...

34 Commits

Author SHA1 Message Date
louis-e
996e8756d0 Mock emit_map_preview_ready 2025-12-01 18:36:29 +01:00
louis-e
0c5bd51ba4 Mock emit_map_preview_ready 2025-12-01 18:32:26 +01:00
louis-e
7965dc3737 Fix CLI build issue 2025-12-01 18:07:52 +01:00
Louis Erbkamm
c54187b43a Merge branch 'main' into benchmark-map-preview 2025-12-01 18:04:14 +01:00
Louis Erbkamm
57a4a801cf Modify benchmark workflow for map preview and checkout
Updated the benchmark workflow to use actions/checkout@v5 and added steps to check for and upload a map preview artifact.
2025-12-01 18:04:08 +01:00
louis-e
beb7b73d11 Include map preview in CI benchmark 2025-12-01 18:02:59 +01:00
Louis Erbkamm
0c47e365bc Merge pull request #644 from louis-e/dependabot/cargo/semver-1.0.27
build(deps): bump semver from 1.0.26 to 1.0.27
2025-12-01 17:47:50 +01:00
Louis Erbkamm
dad3ab3b34 Merge pull request #643 from louis-e/dependabot/github_actions/actions/checkout-6
build(deps): bump actions/checkout from 5 to 6
2025-12-01 17:47:40 +01:00
Louis Erbkamm
b8b63a2bc5 Merge pull request #647 from louis-e/map-preview
Display map preview in GUI
2025-12-01 17:44:05 +01:00
louis-e
cab20b5e50 Fix clippy lint 2025-12-01 17:43:43 +01:00
louis-e
0e879837fa Remove block category comments 2025-12-01 17:42:37 +01:00
louis-e
92be2ccf00 Improve map preview generation time 2025-12-01 17:38:10 +01:00
louis-e
3b76d707d9 Display map preview in GUI 2025-12-01 17:08:54 +01:00
dependabot[bot]
be8559dee7 build(deps): bump semver from 1.0.26 to 1.0.27
Bumps [semver](https://github.com/dtolnay/semver) from 1.0.26 to 1.0.27.
- [Release notes](https://github.com/dtolnay/semver/releases)
- [Commits](https://github.com/dtolnay/semver/compare/1.0.26...1.0.27)

---
updated-dependencies:
- dependency-name: semver
  dependency-version: 1.0.27
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-01 02:41:00 +00:00
dependabot[bot]
94eda2fad3 build(deps): bump actions/checkout from 5 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-01 02:27:47 +00:00
Louis Erbkamm
7d86854e3c Merge pull request #640 from louis-e/reintroduce-sutherland-hodgeman
Reintroduce Sutherland-Hodgman Clipping Algorithm
2025-11-28 11:28:25 +01:00
louis-e
cddaa89d35 Fix polyline clipping for segments crossing bbox with external endpoints 2025-11-28 11:12:17 +01:00
louis-e
453845977d Fix polyline clipping for segments crossing bbox with external endpoints 2025-11-28 11:10:43 +01:00
louis-e
4e196e51bd Move sutherland hodgman into own clipping file 2025-11-28 10:57:47 +01:00
louis-e
ea4dc5dc08 Remove unused dependencies 2025-11-28 10:40:06 +01:00
louis-e
c56ff83094 Fix cargo fmt 2025-11-28 01:43:55 +01:00
louis-e
2b40a520ff Reintroduce initial Sutherland-Hodgman clipping 2025-11-28 01:43:16 +01:00
louis-e
a192be981a Fix clippy 2025-11-26 15:02:12 +01:00
louis-e
eb77bca10d Refactor code comments 2025-11-26 14:49:41 +01:00
louis-e
4a891c3603 Clean up code 2025-11-26 14:40:36 +01:00
louis-e
84adfdd931 Remove floodfill abort artifacts 2025-11-26 14:32:24 +01:00
louis-e
823b6ba052 Remove debugging changes 2025-11-26 14:22:04 +01:00
louis-e
2ba8157ec9 Remove debug logging 2025-11-26 14:15:20 +01:00
louis-e
7235ba0be9 Merge remote changes, keeping our Sutherland-Hodgman implementation 2025-11-26 13:53:53 +01:00
louis-e
dee580c564 fix: cargo fmt 2025-11-26 13:48:32 +01:00
louis-e
41fc5662e0 fix: restore performance with Sutherland-Hodgman clipping and correct water rendering
- Fix O(n*m) performance regression in highway processing by building connectivity map once
- Store unclipped ways in ways_map for proper relation member merging (merge_loopy_loops)
- Use clipped ways for standalone way processing
- Add empty vector guard in merge_loopy_loops to prevent panic
- Expose build_highway_connectivity_map as public API
- Add debug_logging module for development diagnostics
2025-11-26 13:39:29 +01:00
louis-e
ac884b8c2a Correctly clip multipolygons 2025-11-23 18:34:10 +01:00
louis-e
7a9b792bee Restore node filtering for performance without breaking water features 2025-11-23 17:34:08 +01:00
Louis Erbkamm
83e9a634e5 Update baseline time and memory in benchmark workflow 2025-11-22 14:45:51 +01:00
20 changed files with 2431 additions and 149 deletions

View File

@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up Rust
uses: dtolnay/rust-toolchain@v1
@@ -48,7 +48,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up Rust
uses: dtolnay/rust-toolchain@v1

View File

@@ -43,7 +43,7 @@ jobs:
- name: Run benchmark command with memory tracking
id: benchmark
run: |
/usr/bin/time -v ./target/release/arnis --path="./world" --terrain --bbox="48.125768 11.552296 48.148565 11.593838" 2> benchmark_log.txt
/usr/bin/time -v ./target/release/arnis --path="./world" --terrain --generate-map --bbox="48.125768 11.552296 48.148565 11.593838" 2> benchmark_log.txt
grep "Maximum resident set size" benchmark_log.txt | awk '{print $6}' > peak_mem_kb.txt
peak_kb=$(cat peak_mem_kb.txt)
peak_mb=$((peak_kb / 1024))
@@ -57,6 +57,25 @@ jobs:
duration=$((end_time - start_time))
echo "duration=$duration" >> $GITHUB_OUTPUT
- name: Check for map preview
id: map_check
run: |
if [ -f "./world/arnis_world_map.png" ]; then
echo "Map preview generated successfully"
echo "map_exists=true" >> $GITHUB_OUTPUT
else
echo "Map preview not found"
echo "map_exists=false" >> $GITHUB_OUTPUT
fi
- name: Upload map preview as artifact
if: steps.map_check.outputs.map_exists == 'true'
uses: actions/upload-artifact@v4
with:
name: world-map-preview
path: ./world/arnis_world_map.png
retention-days: 60
- name: Format duration and generate summary
id: comment_body
run: |
@@ -65,7 +84,7 @@ jobs:
seconds=$((duration % 60))
peak_mem=${{ steps.benchmark.outputs.peak_memory }}
baseline_time=135
baseline_time=69
diff=$((duration - baseline_time))
abs_diff=${diff#-}
@@ -79,7 +98,7 @@ jobs:
verdict="🚨 This PR **drastically worsens generation time**."
fi
baseline_mem=5865
baseline_mem=935
mem_annotation=""
if [ "$peak_mem" -gt 2000 ]; then
mem_diff=$((peak_mem - baseline_mem))
@@ -87,17 +106,28 @@ jobs:
mem_annotation=" (↗ ${mem_percent}% more)"
fi
# Get current timestamp
benchmark_time=$(date -u "+%Y-%m-%d %H:%M:%S UTC")
run_url="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
{
echo "summary<<EOF"
echo "⏱️ Benchmark run finished in **${minutes}m ${seconds}s**"
echo "🧠 Peak memory usage: **${peak_mem} MB**${mem_annotation}"
echo "## ⏱️ Benchmark Results"
echo ""
echo "📈 Compared against baseline: **${baseline_time}s**"
echo "🧮 Delta: **${diff}s**"
echo "🔢 Commit: [\`${GITHUB_SHA:0:7}\`](https://github.com/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA})"
echo "| Metric | Value |"
echo "|--------|-------|"
echo "| Duration | **${minutes}m ${seconds}s** |"
echo "| Peak Memory | **${peak_mem} MB**${mem_annotation} |"
echo "| Baseline | **${baseline_time}s** |"
echo "| Delta | **${diff}s** |"
echo "| Commit | [\`${GITHUB_SHA:0:7}\`](https://github.com/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}) |"
echo ""
echo "${verdict}"
echo ""
echo "---"
echo ""
echo "📅 **Last benchmark:** ${benchmark_time} | 📥 [Download generated world map](${run_url}#artifacts)"
echo ""
echo "_You can retrigger the benchmark by commenting \`retrigger-benchmark\`._"
echo "EOF"
} >> "$GITHUB_OUTPUT"

View File

@@ -30,7 +30,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up Rust
uses: dtolnay/rust-toolchain@v1
@@ -124,7 +124,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Download Windows build artifact
uses: actions/download-artifact@v5

30
Cargo.lock generated
View File

@@ -184,6 +184,7 @@ dependencies = [
name = "arnis"
version = "2.3.1"
dependencies = [
"base64 0.22.1",
"clap",
"colored",
"dirs",
@@ -1141,7 +1142,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4603,19 +4604,21 @@ dependencies = [
[[package]]
name = "semver"
version = "1.0.26"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde"
version = "1.0.217"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
@@ -4640,10 +4643,19 @@ dependencies = [
]
[[package]]
name = "serde_derive"
version = "1.0.217"
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
@@ -5430,7 +5442,7 @@ dependencies = [
"getrandom 0.3.3",
"once_cell",
"rustix 1.0.7",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]

View File

@@ -20,6 +20,7 @@ gui = ["tauri", "tauri-plugin-log", "tauri-plugin-shell", "tokio", "rfd", "dirs"
tauri-build = {version = "2", optional = true}
[dependencies]
base64 = "0.22.1"
clap = { version = "4.5", features = ["derive", "env"] }
colored = "3.0.0"
dirs = {version = "6.0.0", optional = true }
@@ -38,7 +39,7 @@ rand = "0.8.5"
rayon = "1.10.0"
reqwest = { version = "0.12.15", features = ["blocking", "json"] }
rfd = { version = "0.15.4", optional = true }
semver = "1.0.26"
semver = "1.0.27"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tauri = { version = "2", optional = true }

View File

@@ -59,6 +59,10 @@ pub struct Args {
#[arg(long, value_parser = parse_duration)]
pub timeout: Option<Duration>,
/// Generate a top-down map preview image after world generation (optional)
#[arg(long)]
pub generate_map: bool,
/// Spawn point coordinates (lat, lng)
#[arg(skip)]
pub spawn_point: Option<(f64, f64)>,

706
src/clipping.rs Normal file
View File

@@ -0,0 +1,706 @@
// Sutherland-Hodgman polygon clipping and related geometry utilities.
//
// Provides bbox clipping for polygons, polylines, and water rings with
// proper corner insertion for closed shapes.
use crate::coordinate_system::cartesian::{XZBBox, XZPoint};
use crate::osm_parser::ProcessedNode;
use std::collections::HashMap;
/// Clips a way to the bounding box using Sutherland-Hodgman for polygons or
/// simple line clipping for polylines. Preserves endpoint IDs for ring assembly.
pub fn clip_way_to_bbox(nodes: &[ProcessedNode], xzbbox: &XZBBox) -> Vec<ProcessedNode> {
if nodes.is_empty() {
return Vec::new();
}
let is_closed = is_closed_polygon(nodes);
if !is_closed {
return clip_polyline_to_bbox(nodes, xzbbox);
}
// If all nodes are inside the bbox, return unchanged
let has_nodes_outside = nodes
.iter()
.any(|node| !xzbbox.contains(&XZPoint::new(node.x, node.z)));
if !has_nodes_outside {
return nodes.to_vec();
}
let min_x = xzbbox.min_x() as f64;
let min_z = xzbbox.min_z() as f64;
let max_x = xzbbox.max_x() as f64;
let max_z = xzbbox.max_z() as f64;
let mut polygon: Vec<(f64, f64)> = nodes.iter().map(|n| (n.x as f64, n.z as f64)).collect();
polygon = clip_polygon_sutherland_hodgman(polygon, min_x, min_z, max_x, max_z);
if polygon.len() < 3 {
return Vec::new();
}
// Final clamping for floating-point errors
for p in &mut polygon {
p.0 = p.0.clamp(min_x, max_x);
p.1 = p.1.clamp(min_z, max_z);
}
let polygon = remove_consecutive_duplicates(polygon);
if polygon.len() < 3 {
return Vec::new();
}
let polygon = insert_bbox_corners(polygon, min_x, min_z, max_x, max_z);
let polygon = remove_consecutive_duplicates(polygon);
if polygon.len() < 3 {
return Vec::new();
}
let way_id = nodes.first().map(|n| n.id).unwrap_or(0);
assign_node_ids_preserving_endpoints(nodes, polygon, way_id)
}
/// Clips a water polygon ring to bbox using Sutherland-Hodgman (post-ring-merge).
pub fn clip_water_ring_to_bbox(
ring: &[ProcessedNode],
xzbbox: &XZBBox,
) -> Option<Vec<ProcessedNode>> {
if ring.is_empty() {
return None;
}
let min_x = xzbbox.min_x() as f64;
let min_z = xzbbox.min_z() as f64;
let max_x = xzbbox.max_x() as f64;
let max_z = xzbbox.max_z() as f64;
// Check if entire ring is inside bbox
let all_inside = ring.iter().all(|n| {
n.x as f64 >= min_x && n.x as f64 <= max_x && n.z as f64 >= min_z && n.z as f64 <= max_z
});
if all_inside {
return Some(ring.to_vec());
}
// Check if entire ring is outside bbox
if is_ring_outside_bbox(ring, min_x, min_z, max_x, max_z) {
return None;
}
// Convert to f64 coordinates and ensure closed
let mut polygon: Vec<(f64, f64)> = ring.iter().map(|n| (n.x as f64, n.z as f64)).collect();
if !polygon.is_empty() && polygon.first() != polygon.last() {
polygon.push(polygon[0]);
}
// Clip with full-range clamping (water uses simpler approach)
polygon = clip_polygon_sutherland_hodgman_simple(polygon, min_x, min_z, max_x, max_z);
if polygon.len() < 3 {
return None;
}
// Verify all points are within bbox
let all_points_inside = polygon
.iter()
.all(|&(x, z)| x >= min_x && x <= max_x && z >= min_z && z <= max_z);
if !all_points_inside {
eprintln!("ERROR: clip_water_ring_to_bbox produced points outside bbox!");
return None;
}
let polygon = insert_bbox_corners(polygon, min_x, min_z, max_x, max_z);
if polygon.len() < 3 {
return None;
}
// Convert back to ProcessedNode with synthetic IDs
let mut result: Vec<ProcessedNode> = polygon
.iter()
.enumerate()
.map(|(i, &(x, z))| ProcessedNode {
id: 1_000_000_000 + i as u64,
tags: HashMap::new(),
x: x.clamp(min_x, max_x).round() as i32,
z: z.clamp(min_z, max_z).round() as i32,
})
.collect();
// Close the loop by matching first and last ID
if !result.is_empty() {
let first_id = result[0].id;
result.last_mut().unwrap().id = first_id;
}
Some(result)
}
// ============================================================================
// Internal helpers
// ============================================================================
/// Checks if a way forms a closed polygon.
fn is_closed_polygon(nodes: &[ProcessedNode]) -> bool {
if nodes.len() < 3 {
return false;
}
let first = nodes.first().unwrap();
let last = nodes.last().unwrap();
first.id == last.id || (first.x == last.x && first.z == last.z)
}
/// Checks if an entire ring is outside the bbox.
fn is_ring_outside_bbox(
ring: &[ProcessedNode],
min_x: f64,
min_z: f64,
max_x: f64,
max_z: f64,
) -> bool {
let all_left = ring.iter().all(|n| (n.x as f64) < min_x);
let all_right = ring.iter().all(|n| (n.x as f64) > max_x);
let all_top = ring.iter().all(|n| (n.z as f64) < min_z);
let all_bottom = ring.iter().all(|n| (n.z as f64) > max_z);
all_left || all_right || all_top || all_bottom
}
/// Clips a polyline (open path) to the bounding box.
fn clip_polyline_to_bbox(nodes: &[ProcessedNode], xzbbox: &XZBBox) -> Vec<ProcessedNode> {
if nodes.is_empty() {
return Vec::new();
}
let min_x = xzbbox.min_x() as f64;
let min_z = xzbbox.min_z() as f64;
let max_x = xzbbox.max_x() as f64;
let max_z = xzbbox.max_z() as f64;
let mut result = Vec::new();
for i in 0..nodes.len() {
let current = &nodes[i];
let current_point = (current.x as f64, current.z as f64);
let current_inside = point_in_bbox(current_point, min_x, min_z, max_x, max_z);
if current_inside {
result.push(current.clone());
}
if i + 1 < nodes.len() {
let next = &nodes[i + 1];
let next_point = (next.x as f64, next.z as f64);
let next_inside = point_in_bbox(next_point, min_x, min_z, max_x, max_z);
if current_inside != next_inside {
// One endpoint inside, one outside, find single intersection
let intersections =
find_bbox_intersections(current_point, next_point, min_x, min_z, max_x, max_z);
for intersection in intersections {
let synthetic_id = nodes[0]
.id
.wrapping_mul(10000000)
.wrapping_add(result.len() as u64);
result.push(ProcessedNode {
id: synthetic_id,
x: intersection.0.round() as i32,
z: intersection.1.round() as i32,
tags: HashMap::new(),
});
}
} else if !current_inside && !next_inside {
// Both endpoints outside, segment might still cross through bbox
let mut intersections =
find_bbox_intersections(current_point, next_point, min_x, min_z, max_x, max_z);
if intersections.len() >= 2 {
// Sort intersections by distance from current point
intersections.sort_by(|a, b| {
let dist_a =
(a.0 - current_point.0).powi(2) + (a.1 - current_point.1).powi(2);
let dist_b =
(b.0 - current_point.0).powi(2) + (b.1 - current_point.1).powi(2);
dist_a
.partial_cmp(&dist_b)
.unwrap_or(std::cmp::Ordering::Equal)
});
for intersection in intersections {
let synthetic_id = nodes[0]
.id
.wrapping_mul(10000000)
.wrapping_add(result.len() as u64);
result.push(ProcessedNode {
id: synthetic_id,
x: intersection.0.round() as i32,
z: intersection.1.round() as i32,
tags: HashMap::new(),
});
}
}
}
}
}
// Preserve endpoint IDs where possible
if result.len() >= 2 {
let tolerance = 50.0;
if let Some(first_orig) = nodes.first() {
if matches_endpoint(
(result[0].x as f64, result[0].z as f64),
first_orig,
tolerance,
) {
result[0].id = first_orig.id;
}
}
if let Some(last_orig) = nodes.last() {
let last_idx = result.len() - 1;
if matches_endpoint(
(result[last_idx].x as f64, result[last_idx].z as f64),
last_orig,
tolerance,
) {
result[last_idx].id = last_orig.id;
}
}
}
result
}
/// Sutherland-Hodgman polygon clipping with edge-specific clamping.
fn clip_polygon_sutherland_hodgman(
mut polygon: Vec<(f64, f64)>,
min_x: f64,
min_z: f64,
max_x: f64,
max_z: f64,
) -> Vec<(f64, f64)> {
// Edges: bottom, right, top, left (counter-clockwise traversal)
let bbox_edges = [
(min_x, min_z, max_x, min_z, 0), // Bottom: clamp z
(max_x, min_z, max_x, max_z, 1), // Right: clamp x
(max_x, max_z, min_x, max_z, 2), // Top: clamp z
(min_x, max_z, min_x, min_z, 3), // Left: clamp x
];
for (edge_x1, edge_z1, edge_x2, edge_z2, edge_idx) in bbox_edges {
if polygon.is_empty() {
break;
}
let mut clipped = Vec::new();
let is_closed = !polygon.is_empty() && polygon.first() == polygon.last();
let edge_count = if is_closed {
polygon.len().saturating_sub(1)
} else {
polygon.len()
};
for i in 0..edge_count {
let current = polygon[i];
let next = polygon.get(i + 1).copied().unwrap_or(polygon[0]);
let current_inside = point_inside_edge(current, edge_x1, edge_z1, edge_x2, edge_z2);
let next_inside = point_inside_edge(next, edge_x1, edge_z1, edge_x2, edge_z2);
if next_inside {
if !current_inside {
if let Some(mut intersection) = line_edge_intersection(
current.0, current.1, next.0, next.1, edge_x1, edge_z1, edge_x2, edge_z2,
) {
// Clamp to current edge only
match edge_idx {
0 => intersection.1 = min_z,
1 => intersection.0 = max_x,
2 => intersection.1 = max_z,
3 => intersection.0 = min_x,
_ => {}
}
clipped.push(intersection);
}
}
clipped.push(next);
} else if current_inside {
if let Some(mut intersection) = line_edge_intersection(
current.0, current.1, next.0, next.1, edge_x1, edge_z1, edge_x2, edge_z2,
) {
match edge_idx {
0 => intersection.1 = min_z,
1 => intersection.0 = max_x,
2 => intersection.1 = max_z,
3 => intersection.0 = min_x,
_ => {}
}
clipped.push(intersection);
}
}
}
polygon = clipped;
}
polygon
}
/// Sutherland-Hodgman with full bbox clamping (simpler, for water rings).
fn clip_polygon_sutherland_hodgman_simple(
mut polygon: Vec<(f64, f64)>,
min_x: f64,
min_z: f64,
max_x: f64,
max_z: f64,
) -> Vec<(f64, f64)> {
let bbox_edges = [
(min_x, min_z, max_x, min_z),
(max_x, min_z, max_x, max_z),
(max_x, max_z, min_x, max_z),
(min_x, max_z, min_x, min_z),
];
for (edge_x1, edge_z1, edge_x2, edge_z2) in bbox_edges {
if polygon.is_empty() {
break;
}
let mut clipped = Vec::new();
for i in 0..(polygon.len().saturating_sub(1)) {
let current = polygon[i];
let next = polygon[i + 1];
let current_inside = point_inside_edge(current, edge_x1, edge_z1, edge_x2, edge_z2);
let next_inside = point_inside_edge(next, edge_x1, edge_z1, edge_x2, edge_z2);
if next_inside {
if !current_inside {
if let Some(mut intersection) = line_edge_intersection(
current.0, current.1, next.0, next.1, edge_x1, edge_z1, edge_x2, edge_z2,
) {
intersection.0 = intersection.0.clamp(min_x, max_x);
intersection.1 = intersection.1.clamp(min_z, max_z);
clipped.push(intersection);
}
}
clipped.push(next);
} else if current_inside {
if let Some(mut intersection) = line_edge_intersection(
current.0, current.1, next.0, next.1, edge_x1, edge_z1, edge_x2, edge_z2,
) {
intersection.0 = intersection.0.clamp(min_x, max_x);
intersection.1 = intersection.1.clamp(min_z, max_z);
clipped.push(intersection);
}
}
}
polygon = clipped;
}
polygon
}
/// Checks if point is inside bbox.
fn point_in_bbox(point: (f64, f64), min_x: f64, min_z: f64, max_x: f64, max_z: f64) -> bool {
point.0 >= min_x && point.0 <= max_x && point.1 >= min_z && point.1 <= max_z
}
/// Checks if point is on the "inside" side of an edge (cross product test).
fn point_inside_edge(
point: (f64, f64),
edge_x1: f64,
edge_z1: f64,
edge_x2: f64,
edge_z2: f64,
) -> bool {
let edge_dx = edge_x2 - edge_x1;
let edge_dz = edge_z2 - edge_z1;
let point_dx = point.0 - edge_x1;
let point_dz = point.1 - edge_z1;
(edge_dx * point_dz - edge_dz * point_dx) >= 0.0
}
/// Finds intersection between a line segment and an edge.
#[allow(clippy::too_many_arguments)]
fn line_edge_intersection(
line_x1: f64,
line_z1: f64,
line_x2: f64,
line_z2: f64,
edge_x1: f64,
edge_z1: f64,
edge_x2: f64,
edge_z2: f64,
) -> Option<(f64, f64)> {
let line_dx = line_x2 - line_x1;
let line_dz = line_z2 - line_z1;
let edge_dx = edge_x2 - edge_x1;
let edge_dz = edge_z2 - edge_z1;
let denom = line_dx * edge_dz - line_dz * edge_dx;
if denom.abs() < 1e-10 {
return None;
}
let dx = edge_x1 - line_x1;
let dz = edge_z1 - line_z1;
let t = (dx * edge_dz - dz * edge_dx) / denom;
if (0.0..=1.0).contains(&t) {
Some((line_x1 + t * line_dx, line_z1 + t * line_dz))
} else {
None
}
}
/// Finds intersections between a line segment and bbox edges.
fn find_bbox_intersections(
start: (f64, f64),
end: (f64, f64),
min_x: f64,
min_z: f64,
max_x: f64,
max_z: f64,
) -> Vec<(f64, f64)> {
let mut intersections = Vec::new();
let bbox_edges = [
(min_x, min_z, max_x, min_z),
(max_x, min_z, max_x, max_z),
(max_x, max_z, min_x, max_z),
(min_x, max_z, min_x, min_z),
];
for (edge_x1, edge_z1, edge_x2, edge_z2) in bbox_edges {
if let Some(intersection) = line_edge_intersection(
start.0, start.1, end.0, end.1, edge_x1, edge_z1, edge_x2, edge_z2,
) {
let on_edge = point_in_bbox(intersection, min_x, min_z, max_x, max_z)
&& ((intersection.0 == min_x || intersection.0 == max_x)
|| (intersection.1 == min_z || intersection.1 == max_z));
if on_edge {
intersections.push(intersection);
}
}
}
intersections
}
/// Returns which bbox edge a point lies on: 0=bottom, 1=right, 2=top, 3=left, -1=interior.
fn get_bbox_edge(point: (f64, f64), min_x: f64, min_z: f64, max_x: f64, max_z: f64) -> i32 {
let eps = 0.5;
let on_left = (point.0 - min_x).abs() < eps;
let on_right = (point.0 - max_x).abs() < eps;
let on_bottom = (point.1 - min_z).abs() < eps;
let on_top = (point.1 - max_z).abs() < eps;
// Handle corners (assign to edge in counter-clockwise order)
if on_bottom && on_left {
return 3;
}
if on_bottom && on_right {
return 0;
}
if on_top && on_right {
return 1;
}
if on_top && on_left {
return 2;
}
if on_bottom {
return 0;
}
if on_right {
return 1;
}
if on_top {
return 2;
}
if on_left {
return 3;
}
-1
}
/// Returns corners to insert when traversing from edge1 to edge2 via shorter path.
fn get_corners_between_edges(
edge1: i32,
edge2: i32,
min_x: f64,
min_z: f64,
max_x: f64,
max_z: f64,
) -> Vec<(f64, f64)> {
if edge1 == edge2 || edge1 < 0 || edge2 < 0 {
return Vec::new();
}
let corners = [
(max_x, min_z), // 0: bottom-right
(max_x, max_z), // 1: top-right
(min_x, max_z), // 2: top-left
(min_x, min_z), // 3: bottom-left
];
let ccw_dist = ((edge2 - edge1 + 4) % 4) as usize;
let cw_dist = ((edge1 - edge2 + 4) % 4) as usize;
// Opposite edges: don't insert corners
if ccw_dist == 2 && cw_dist == 2 {
return Vec::new();
}
let mut result = Vec::new();
if ccw_dist <= cw_dist {
let mut current = edge1;
for _ in 0..ccw_dist {
result.push(corners[current as usize]);
current = (current + 1) % 4;
}
} else {
let mut current = edge1;
for _ in 0..cw_dist {
current = (current + 4 - 1) % 4;
result.push(corners[current as usize]);
}
}
result
}
/// Inserts bbox corners where polygon transitions between different bbox edges.
fn insert_bbox_corners(
polygon: Vec<(f64, f64)>,
min_x: f64,
min_z: f64,
max_x: f64,
max_z: f64,
) -> Vec<(f64, f64)> {
if polygon.len() < 3 {
return polygon;
}
let mut result = Vec::with_capacity(polygon.len() + 4);
for i in 0..polygon.len() {
let current = polygon[i];
let next = polygon[(i + 1) % polygon.len()];
result.push(current);
let edge1 = get_bbox_edge(current, min_x, min_z, max_x, max_z);
let edge2 = get_bbox_edge(next, min_x, min_z, max_x, max_z);
if edge1 >= 0 && edge2 >= 0 && edge1 != edge2 {
for corner in get_corners_between_edges(edge1, edge2, min_x, min_z, max_x, max_z) {
result.push(corner);
}
}
}
result
}
/// Removes consecutive duplicate points (within epsilon tolerance).
fn remove_consecutive_duplicates(polygon: Vec<(f64, f64)>) -> Vec<(f64, f64)> {
if polygon.is_empty() {
return polygon;
}
let eps = 0.1;
let mut result: Vec<(f64, f64)> = Vec::with_capacity(polygon.len());
for p in &polygon {
if let Some(last) = result.last() {
if (p.0 - last.0).abs() < eps && (p.1 - last.1).abs() < eps {
continue;
}
}
result.push(*p);
}
// Check first/last duplicates for closed polygons
if result.len() > 1 {
let first = result.first().unwrap();
let last = result.last().unwrap();
if (first.0 - last.0).abs() < eps && (first.1 - last.1).abs() < eps {
result.pop();
}
}
result
}
/// Checks if a clipped coordinate matches an original endpoint.
fn matches_endpoint(coord: (f64, f64), endpoint: &ProcessedNode, tolerance: f64) -> bool {
let dx = (coord.0 - endpoint.x as f64).abs();
let dz = (coord.1 - endpoint.z as f64).abs();
dx * dx + dz * dz < tolerance * tolerance
}
/// Assigns node IDs to clipped coordinates, preserving original endpoint IDs.
fn assign_node_ids_preserving_endpoints(
original_nodes: &[ProcessedNode],
clipped_coords: Vec<(f64, f64)>,
way_id: u64,
) -> Vec<ProcessedNode> {
if clipped_coords.is_empty() {
return Vec::new();
}
let original_first = original_nodes.first();
let original_last = original_nodes.last();
let tolerance = 50.0;
let last_index = clipped_coords.len() - 1;
clipped_coords
.into_iter()
.enumerate()
.map(|(i, coord)| {
let is_first = i == 0;
let is_last = i == last_index;
if is_first || is_last {
if let Some(first) = original_first {
if matches_endpoint(coord, first, tolerance) {
return ProcessedNode {
id: first.id,
x: coord.0.round() as i32,
z: coord.1.round() as i32,
tags: HashMap::new(),
};
}
}
if let Some(last) = original_last {
if matches_endpoint(coord, last, tolerance) {
return ProcessedNode {
id: last.id,
x: coord.0.round() as i32,
z: coord.1.round() as i32,
tags: HashMap::new(),
};
}
}
}
ProcessedNode {
id: way_id.wrapping_mul(10000000).wrapping_add(i as u64),
x: coord.0.round() as i32,
z: coord.1.round() as i32,
tags: HashMap::new(),
}
})
.collect()
}

View File

@@ -4,8 +4,9 @@ use crate::coordinate_system::cartesian::XZBBox;
use crate::coordinate_system::geographic::LLBBox;
use crate::element_processing::*;
use crate::ground::Ground;
use crate::map_renderer;
use crate::osm_parser::ProcessedElement;
use crate::progress::emit_gui_progress_update;
use crate::progress::{emit_gui_progress_update, emit_map_preview_ready};
#[cfg(feature = "gui")]
use crate::telemetry::{send_log, LogLevel};
use crate::world_editor::WorldEditor;
@@ -25,6 +26,9 @@ pub fn generate_world(
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(&ground);
@@ -66,7 +70,7 @@ pub fn generate_world(
if way.tags.contains_key("building") || way.tags.contains_key("building:part") {
buildings::generate_buildings(&mut editor, way, args, None);
} else if way.tags.contains_key("highway") {
highways::generate_highways(&mut editor, element, args, &elements);
highways::generate_highways(&mut editor, element, args, &highway_connectivity);
} else if way.tags.contains_key("landuse") {
landuse::generate_landuse(&mut editor, way, args);
} else if way.tags.contains_key("natural") {
@@ -80,7 +84,7 @@ pub fn generate_world(
} else if let Some(val) = way.tags.get("waterway") {
if val == "dock" {
// docks count as water areas
water_areas::generate_water_area_from_way(&mut editor, way);
water_areas::generate_water_area_from_way(&mut editor, way, &xzbbox);
} else {
waterways::generate_waterways(&mut editor, way);
}
@@ -111,7 +115,7 @@ pub fn generate_world(
} else if node.tags.contains_key("barrier") {
barriers::generate_barrier_nodes(&mut editor, node);
} else if node.tags.contains_key("highway") {
highways::generate_highways(&mut editor, element, args, &elements);
highways::generate_highways(&mut editor, element, args, &highway_connectivity);
} else if node.tags.contains_key("tourism") {
tourisms::generate_tourisms(&mut editor, node);
} else if node.tags.contains_key("man_made") {
@@ -128,7 +132,7 @@ pub fn generate_world(
.map(|val| val == "water" || val == "bay")
.unwrap_or(false)
{
water_areas::generate_water_areas_from_relation(&mut editor, rel);
water_areas::generate_water_areas_from_relation(&mut editor, rel, &xzbbox);
} else if rel.tags.contains_key("natural") {
natural::generate_natural_from_relation(&mut editor, rel, args);
} else if rel.tags.contains_key("landuse") {
@@ -261,5 +265,55 @@ pub fn generate_world(
emit_gui_progress_update(100.0, "Done! World generation completed.");
println!("{}", "Done! World generation completed.".green().bold());
// Generate top-down map preview:
// - Always for GUI mode (non-blocking, runs in background)
// - Only when --generate-map flag is set for CLI mode (blocking, waits for completion)
#[cfg(feature = "gui")]
let should_generate_map = true;
#[cfg(not(feature = "gui"))]
let should_generate_map = args.generate_map;
if should_generate_map {
let world_path = args.path.clone();
let bounds = (
xzbbox.min_x(),
xzbbox.max_x(),
xzbbox.min_z(),
xzbbox.max_z(),
);
let map_thread = std::thread::spawn(move || {
// Use catch_unwind to prevent any panic from affecting the application
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
map_renderer::render_world_map(&world_path, bounds.0, bounds.1, bounds.2, bounds.3)
}));
match result {
Ok(Ok(_path)) => {
// Notify the GUI that the map preview is ready
emit_map_preview_ready();
}
Ok(Err(e)) => {
eprintln!("Warning: Failed to generate map preview: {}", e);
}
Err(_) => {
eprintln!("Warning: Map preview generation panicked unexpectedly");
}
}
});
// In CLI mode, wait for map generation to complete before exiting
// In GUI mode, let it run in background to keep UI responsive
#[cfg(not(feature = "gui"))]
{
let _ = map_thread.join();
}
// In GUI mode, we don't join, let the thread run in background
#[cfg(feature = "gui")]
drop(map_thread);
}
Ok(())
}

View File

@@ -7,19 +7,21 @@ use crate::osm_parser::{ProcessedElement, ProcessedWay};
use crate::world_editor::WorldEditor;
use std::collections::HashMap;
/// Type alias for highway connectivity map
pub type HighwayConnectivityMap = HashMap<(i32, i32), Vec<i32>>;
/// Generates highways with elevation support based on layer tags and connectivity analysis
pub fn generate_highways(
editor: &mut WorldEditor,
element: &ProcessedElement,
args: &Args,
all_elements: &[ProcessedElement],
highway_connectivity: &HighwayConnectivityMap,
) {
let highway_connectivity = build_highway_connectivity_map(all_elements);
generate_highways_internal(editor, element, args, &highway_connectivity);
generate_highways_internal(editor, element, args, highway_connectivity);
}
/// Build a connectivity map for highway endpoints to determine where slopes are needed
fn build_highway_connectivity_map(elements: &[ProcessedElement]) -> HashMap<(i32, i32), Vec<i32>> {
/// Build a connectivity map for highway endpoints to determine where slopes are needed.
pub fn build_highway_connectivity_map(elements: &[ProcessedElement]) -> HighwayConnectivityMap {
let mut connectivity_map: HashMap<(i32, i32), Vec<i32>> = HashMap::new();
for element in elements {

View File

@@ -1,18 +1,24 @@
use geo::orient::{Direction, Orient};
use geo::{Contains, Intersects, LineString, Point, Polygon, Rect};
use std::time::Instant;
use crate::clipping::clip_water_ring_to_bbox;
use crate::{
block_definitions::WATER,
coordinate_system::cartesian::XZPoint,
coordinate_system::cartesian::{XZBBox, XZPoint},
osm_parser::{ProcessedMemberRole, ProcessedNode, ProcessedRelation, ProcessedWay},
world_editor::WorldEditor,
};
pub fn generate_water_area_from_way(editor: &mut WorldEditor, element: &ProcessedWay) {
pub fn generate_water_area_from_way(
editor: &mut WorldEditor,
element: &ProcessedWay,
_xzbbox: &XZBBox,
) {
let start_time = Instant::now();
let outers = [element.nodes.clone()];
if !verify_loopy_loops(&outers) {
if !verify_closed_rings(&outers) {
println!("Skipping way {} due to invalid polygon", element.id);
return;
}
@@ -20,7 +26,11 @@ pub fn generate_water_area_from_way(editor: &mut WorldEditor, element: &Processe
generate_water_areas(editor, &outers, &[], start_time);
}
pub fn generate_water_areas_from_relation(editor: &mut WorldEditor, element: &ProcessedRelation) {
pub fn generate_water_areas_from_relation(
editor: &mut WorldEditor,
element: &ProcessedRelation,
xzbbox: &XZBBox,
) {
let start_time = Instant::now();
// Check if this is a water relation (either with water tag or natural=water)
@@ -52,14 +62,63 @@ pub fn generate_water_areas_from_relation(editor: &mut WorldEditor, element: &Pr
}
}
merge_loopy_loops(&mut outers);
if !verify_loopy_loops(&outers) {
println!("Skipping relation {} due to invalid polygon", element.id);
return;
// Preserve OSM-defined outer/inner roles without modification
merge_way_segments(&mut outers);
// Clip assembled rings to bbox (must happen after merging to preserve ring connectivity)
outers = outers
.into_iter()
.filter_map(|ring| clip_water_ring_to_bbox(&ring, xzbbox))
.collect();
merge_way_segments(&mut inners);
inners = inners
.into_iter()
.filter_map(|ring| clip_water_ring_to_bbox(&ring, xzbbox))
.collect();
if !verify_closed_rings(&outers) {
// For clipped multipolygons, some loops may not close perfectly
// Instead of force-closing with straight lines (which creates wedges),
// filter out unclosed loops and only render the properly closed ones
// Filter: Keep only loops that are already closed OR can be closed within 1 block
outers.retain(|loop_nodes| {
if loop_nodes.len() < 3 {
return false;
}
let first = &loop_nodes[0];
let last = loop_nodes.last().unwrap();
let dx = (first.x - last.x).abs();
let dz = (first.z - last.z).abs();
// Keep if already closed by ID or endpoints are within 1 block
first.id == last.id || (dx <= 1 && dz <= 1)
});
// Now close the remaining loops that are within 1 block tolerance
for loop_nodes in outers.iter_mut() {
let first = loop_nodes[0].clone();
let last_idx = loop_nodes.len() - 1;
if loop_nodes[0].id != loop_nodes[last_idx].id {
// Endpoints are close (within tolerance), close the loop
loop_nodes.push(first);
}
}
// If no valid outer loops remain, skip the relation
if outers.is_empty() {
return;
}
// Verify again after filtering and closing
if !verify_closed_rings(&outers) {
println!("Skipping relation {} due to invalid polygon", element.id);
return;
}
}
merge_loopy_loops(&mut inners);
if !verify_loopy_loops(&inners) {
merge_way_segments(&mut inners);
if !verify_closed_rings(&inners) {
println!("Skipping relation {} due to invalid polygon", element.id);
return;
}
@@ -73,8 +132,34 @@ fn generate_water_areas(
inners: &[Vec<ProcessedNode>],
start_time: Instant,
) {
let (min_x, min_z) = editor.get_min_coords();
let (max_x, max_z) = editor.get_max_coords();
// Calculate polygon bounding box to limit fill area
let mut poly_min_x = i32::MAX;
let mut poly_min_z = i32::MAX;
let mut poly_max_x = i32::MIN;
let mut poly_max_z = i32::MIN;
for outer in outers {
for node in outer {
poly_min_x = poly_min_x.min(node.x);
poly_min_z = poly_min_z.min(node.z);
poly_max_x = poly_max_x.max(node.x);
poly_max_z = poly_max_z.max(node.z);
}
}
// If no valid bounds, nothing to fill
if poly_min_x == i32::MAX || poly_max_x == i32::MIN {
return;
}
// Clamp to world bounds just in case
let (world_min_x, world_min_z) = editor.get_min_coords();
let (world_max_x, world_max_z) = editor.get_max_coords();
let min_x = poly_min_x.max(world_min_x);
let min_z = poly_min_z.max(world_min_z);
let max_x = poly_max_x.min(world_max_x);
let max_z = poly_max_z.min(world_max_z);
let outers_xz: Vec<Vec<XZPoint>> = outers
.iter()
.map(|x| x.iter().map(|y| y.xz()).collect::<Vec<_>>())
@@ -89,13 +174,23 @@ fn generate_water_areas(
);
}
// Merges ways that share nodes into full loops
fn merge_loopy_loops(loops: &mut Vec<Vec<ProcessedNode>>) {
/// Merges way segments that share endpoints into closed rings.
fn merge_way_segments(rings: &mut Vec<Vec<ProcessedNode>>) {
let mut removed: Vec<usize> = vec![];
let mut merged: Vec<Vec<ProcessedNode>> = vec![];
for i in 0..loops.len() {
for j in 0..loops.len() {
// Match nodes by ID or proximity (handles synthetic nodes from bbox clipping)
let nodes_match = |a: &ProcessedNode, b: &ProcessedNode| -> bool {
if a.id == b.id {
return true;
}
let dx = (a.x - b.x).abs();
let dz = (a.z - b.z).abs();
dx <= 1 && dz <= 1
};
for i in 0..rings.len() {
for j in 0..rings.len() {
if i == j {
continue;
}
@@ -104,20 +199,29 @@ fn merge_loopy_loops(loops: &mut Vec<Vec<ProcessedNode>>) {
continue;
}
let x: &Vec<ProcessedNode> = &loops[i];
let y: &Vec<ProcessedNode> = &loops[j];
let x: &Vec<ProcessedNode> = &rings[i];
let y: &Vec<ProcessedNode> = &rings[j];
// it's looped already
if x[0].id == x.last().unwrap().id {
// Skip empty rings (can happen after clipping)
if x.is_empty() || y.is_empty() {
continue;
}
// it's looped already
if y[0].id == y.last().unwrap().id {
let x_first = &x[0];
let x_last = x.last().unwrap();
let y_first = &y[0];
let y_last = y.last().unwrap();
// Skip already-closed rings
if nodes_match(x_first, x_last) {
continue;
}
if x[0].id == y[0].id {
if nodes_match(y_first, y_last) {
continue;
}
if nodes_match(x_first, y_first) {
removed.push(i);
removed.push(j);
@@ -125,7 +229,7 @@ fn merge_loopy_loops(loops: &mut Vec<Vec<ProcessedNode>>) {
x.reverse();
x.extend(y.iter().skip(1).cloned());
merged.push(x);
} else if x.last().unwrap().id == y.last().unwrap().id {
} else if nodes_match(x_last, y_last) {
removed.push(i);
removed.push(j);
@@ -133,7 +237,7 @@ fn merge_loopy_loops(loops: &mut Vec<Vec<ProcessedNode>>) {
x.extend(y.iter().rev().skip(1).cloned());
merged.push(x);
} else if x[0].id == y.last().unwrap().id {
} else if nodes_match(x_first, y_last) {
removed.push(i);
removed.push(j);
@@ -141,7 +245,7 @@ fn merge_loopy_loops(loops: &mut Vec<Vec<ProcessedNode>>) {
y.extend(x.iter().skip(1).cloned());
merged.push(y);
} else if x.last().unwrap().id == y[0].id {
} else if nodes_match(x_last, y_first) {
removed.push(i);
removed.push(j);
@@ -156,24 +260,35 @@ fn merge_loopy_loops(loops: &mut Vec<Vec<ProcessedNode>>) {
removed.sort();
for r in removed.iter().rev() {
loops.remove(*r);
rings.remove(*r);
}
let merged_len: usize = merged.len();
for m in merged {
loops.push(m);
rings.push(m);
}
if merged_len > 0 {
merge_loopy_loops(loops);
merge_way_segments(rings);
}
}
fn verify_loopy_loops(loops: &[Vec<ProcessedNode>]) -> bool {
let mut valid: bool = true;
for l in loops {
if l[0].id != l.last().unwrap().id {
eprintln!("WARN: Disconnected loop");
/// Verifies all rings are properly closed (first node matches last).
fn verify_closed_rings(rings: &[Vec<ProcessedNode>]) -> bool {
let mut valid = true;
for ring in rings {
let first = &ring[0];
let last = ring.last().unwrap();
// Check if ring is closed (by ID or proximity)
let is_closed = first.id == last.id || {
let dx = (first.x - last.x).abs();
let dz = (first.z - last.z).abs();
dx <= 1 && dz <= 1
};
if !is_closed {
eprintln!("WARN: Disconnected ring");
valid = false;
}
}
@@ -195,6 +310,7 @@ fn inverse_floodfill(
editor: &mut WorldEditor,
start_time: Instant,
) {
// Convert to geo Polygons with normalized winding order
let inners: Vec<_> = inners
.into_iter()
.map(|x| {
@@ -206,6 +322,7 @@ fn inverse_floodfill(
),
vec![],
)
.orient(Direction::Default)
})
.collect();
@@ -220,6 +337,7 @@ fn inverse_floodfill(
),
vec![],
)
.orient(Direction::Default)
})
.collect();

View File

@@ -68,14 +68,11 @@ fn optimized_flood_fill_area(
// Pre-allocate queue with reasonable capacity to avoid reallocations
let mut queue = VecDeque::with_capacity(1024);
let mut iterations = 0u64;
const MAX_ITERATIONS: u64 = 1_000_000; // Safety limit to prevent infinite loops
for z in (min_z..=max_z).step_by(step_z as usize) {
for x in (min_x..=max_x).step_by(step_x as usize) {
// Check timeout more frequently for small areas
#[allow(clippy::manual_is_multiple_of)]
if iterations % 50 == 0 {
// Fast timeout check, only every few iterations
if filled_area.len() % 100 == 0 {
if let Some(timeout) = timeout {
if start_time.elapsed() > *timeout {
return filled_area;
@@ -83,16 +80,6 @@ fn optimized_flood_fill_area(
}
}
// Safety check: prevent infinite loops
iterations += 1;
if iterations > MAX_ITERATIONS {
eprintln!(
"Warning: Flood fill exceeded max iterations ({}), aborting",
MAX_ITERATIONS
);
return filled_area;
}
// Skip if already visited or not inside polygon
if global_visited.contains(&(x, z))
|| !polygon.contains(&Point::new(x as f64, z as f64))
@@ -106,26 +93,6 @@ fn optimized_flood_fill_area(
global_visited.insert((x, z));
while let Some((curr_x, curr_z)) = queue.pop_front() {
// Additional iteration check inside inner loop
iterations += 1;
if iterations > MAX_ITERATIONS {
eprintln!(
"Warning: Flood fill exceeded max iterations ({}), aborting",
MAX_ITERATIONS
);
return filled_area;
}
// Timeout check in inner loop for problematic polygons
#[allow(clippy::manual_is_multiple_of)]
if iterations % 1000 == 0 {
if let Some(timeout) = timeout {
if start_time.elapsed() > *timeout {
return filled_area;
}
}
}
// Add current point to filled area
filled_area.push((curr_x, curr_z));
@@ -188,32 +155,18 @@ fn original_flood_fill_area(
// Pre-allocate queue and reserve space for filled_area
let mut queue: VecDeque<(i32, i32)> = VecDeque::with_capacity(2048);
filled_area.reserve(1000); // Reserve space to reduce reallocations
let mut iterations = 0u64;
const MAX_ITERATIONS: u64 = 1_000_000; // Safety limit to prevent infinite loops
// Scan for multiple seed points to handle U-shapes and concave polygons
for z in (min_z..=max_z).step_by(step_z as usize) {
for x in (min_x..=max_x).step_by(step_x as usize) {
// Check timeout more frequently for problematic polygons
#[allow(clippy::manual_is_multiple_of)]
if iterations % 50 == 0 {
if let Some(timeout) = timeout {
if &start_time.elapsed() > timeout {
return filled_area;
}
// Reduced timeout checking frequency for better performance
// Use manual % check since is_multiple_of() is unstable on stable Rust
if let Some(timeout) = timeout {
if &start_time.elapsed() > timeout {
return filled_area;
}
}
// Safety check: prevent infinite loops
iterations += 1;
if iterations > MAX_ITERATIONS {
eprintln!(
"Warning: Flood fill exceeded max iterations ({}), aborting",
MAX_ITERATIONS
);
return filled_area;
}
// Skip if already processed or not inside polygon
if global_visited.contains(&(x, z))
|| !polygon.contains(&Point::new(x as f64, z as f64))
@@ -227,26 +180,6 @@ fn original_flood_fill_area(
global_visited.insert((x, z));
while let Some((curr_x, curr_z)) = queue.pop_front() {
// Additional iteration check inside inner loop
iterations += 1;
if iterations > MAX_ITERATIONS {
eprintln!(
"Warning: Flood fill exceeded max iterations ({}), aborting",
MAX_ITERATIONS
);
return filled_area;
}
// Timeout check in inner loop
#[allow(clippy::manual_is_multiple_of)]
if iterations % 1000 == 0 {
if let Some(timeout) = timeout {
if &start_time.elapsed() > timeout {
return filled_area;
}
}
}
// Only check polygon containment once per point when adding to filled_area
if polygon.contains(&Point::new(curr_x as f64, curr_z as f64)) {
filled_area.push((curr_x, curr_z));

View File

@@ -100,7 +100,8 @@ pub fn run_gui() {
gui_select_world,
gui_start_generation,
gui_get_version,
gui_check_for_updates
gui_check_for_updates,
gui_get_world_map_data
])
.setup(|app| {
let app_handle = app.handle();
@@ -663,6 +664,63 @@ fn gui_check_for_updates() -> Result<bool, String> {
}
}
/// Returns the world map image data as base64 and geo bounds for overlay display.
/// Returns None if the map image or metadata doesn't exist.
#[tauri::command]
fn gui_get_world_map_data(world_path: String) -> Result<Option<WorldMapData>, String> {
let world_dir = PathBuf::from(&world_path);
let map_path = world_dir.join("arnis_world_map.png");
let metadata_path = world_dir.join("metadata.json");
// Check if both files exist
if !map_path.exists() || !metadata_path.exists() {
return Ok(None);
}
// Read and encode the map image as base64
let image_data = fs::read(&map_path).map_err(|e| format!("Failed to read map image: {e}"))?;
let base64_image =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &image_data);
// Read metadata
let metadata_content =
fs::read_to_string(&metadata_path).map_err(|e| format!("Failed to read metadata: {e}"))?;
let metadata: serde_json::Value = serde_json::from_str(&metadata_content)
.map_err(|e| format!("Failed to parse metadata: {e}"))?;
// Extract geo bounds (metadata uses camelCase from serde)
let min_lat = metadata["minGeoLat"]
.as_f64()
.ok_or("Missing minGeoLat in metadata")?;
let max_lat = metadata["maxGeoLat"]
.as_f64()
.ok_or("Missing maxGeoLat in metadata")?;
let min_lon = metadata["minGeoLon"]
.as_f64()
.ok_or("Missing minGeoLon in metadata")?;
let max_lon = metadata["maxGeoLon"]
.as_f64()
.ok_or("Missing maxGeoLon in metadata")?;
Ok(Some(WorldMapData {
image_base64: format!("data:image/png;base64,{}", base64_image),
min_lat,
max_lat,
min_lon,
max_lon,
}))
}
/// Data structure for world map overlay
#[derive(serde::Serialize)]
struct WorldMapData {
image_base64: String,
min_lat: f64,
max_lat: f64,
min_lon: f64,
max_lon: f64,
}
#[tauri::command]
#[allow(clippy::too_many_arguments)]
#[allow(unused_variables)]
@@ -767,6 +825,7 @@ fn gui_start_generation(
fillground: fillground_enabled,
debug: false,
timeout: Some(std::time::Duration::from_secs(floodfill_timeout)),
generate_map: true,
spawn_point,
};
@@ -818,7 +877,6 @@ fn gui_start_generation(
&mut xzbbox,
&mut ground,
);
send_log(LogLevel::Info, "Map transformation completed.");
let _ = data_processing::generate_world(
parsed_elements,

34
src/gui/css/bbox.css vendored
View File

@@ -344,4 +344,38 @@ body,
filter: blur(1px) sepia(1) invert(1);
transition: all 1s ease;
}
/* World Preview Button in Edit Toolbar */
.leaflet-draw-toolbar .leaflet-draw-edit-preview {
background-position: -31px -2px;
}
.leaflet-draw-toolbar .leaflet-draw-edit-preview.disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
.leaflet-draw-toolbar .leaflet-draw-edit-preview.active {
background-color: #a0d0ff;
}
.world-preview-slider-container {
padding: 6px 8px !important;
background: white !important;
background-clip: padding-box;
}
.world-preview-slider-container a {
display: none !important;
}
.world-preview-slider {
width: 80px;
height: 8px;
cursor: pointer;
accent-color: #3887BE;
display: block;
margin: 0;
}

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

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

@@ -558,11 +558,208 @@ $(document).ready(function () {
var savedTheme = localStorage.getItem('selectedTileTheme') || 'osm';
changeTileTheme(savedTheme);
// Listen for theme changes from parent window (settings modal)
// World overlay state
var worldOverlay = null;
var worldOverlayData = null;
var worldOverlayEnabled = false;
var worldPreviewAvailable = false;
var sliderControl = null;
// Create the opacity slider as a proper Leaflet control
var SliderControl = L.Control.extend({
options: { position: 'topleft' },
onAdd: function(map) {
var container = L.DomUtil.create('div', 'leaflet-bar world-preview-slider-container');
container.id = 'world-preview-slider-container';
container.style.display = 'none';
var slider = L.DomUtil.create('input', 'world-preview-slider', container);
slider.type = 'range';
slider.min = '0';
slider.max = '100';
slider.value = '50';
slider.id = 'world-preview-opacity';
slider.title = 'Overlay Opacity';
L.DomEvent.on(slider, 'input', function(e) {
if (worldOverlay) {
worldOverlay.setOpacity(e.target.value / 100);
}
});
// Prevent all map interactions
L.DomEvent.disableClickPropagation(container);
L.DomEvent.disableScrollPropagation(container);
L.DomEvent.on(container, 'mousedown', L.DomEvent.stopPropagation);
L.DomEvent.on(container, 'touchstart', L.DomEvent.stopPropagation);
L.DomEvent.on(slider, 'mousedown', L.DomEvent.stopPropagation);
L.DomEvent.on(slider, 'touchstart', L.DomEvent.stopPropagation);
return container;
}
});
// Function to add world preview button to the draw control's edit toolbar
function addWorldPreviewToEditToolbar() {
// Find the edit toolbar (contains Edit layers and Delete layers buttons)
var editToolbar = document.querySelector('.leaflet-draw-toolbar:not(.leaflet-draw-toolbar-top)');
if (!editToolbar) {
// Try finding by the edit/delete buttons
var deleteBtn = document.querySelector('.leaflet-draw-edit-remove');
if (deleteBtn) {
editToolbar = deleteBtn.parentElement;
}
}
if (editToolbar) {
// Create the preview button
var toggleBtn = document.createElement('a');
toggleBtn.className = 'leaflet-draw-edit-preview disabled';
toggleBtn.href = '#';
toggleBtn.title = 'Show World Preview (not available yet)';
toggleBtn.id = 'world-preview-btn';
toggleBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
if (worldPreviewAvailable) {
toggleWorldOverlay();
}
});
editToolbar.appendChild(toggleBtn);
// Add the slider control to the map
sliderControl = new SliderControl();
map.addControl(sliderControl);
}
}
// Toggle world overlay function
function toggleWorldOverlay() {
if (!worldPreviewAvailable || !worldOverlayData) return;
worldOverlayEnabled = !worldOverlayEnabled;
var btn = document.getElementById('world-preview-btn');
var sliderContainer = document.getElementById('world-preview-slider-container');
if (worldOverlayEnabled) {
// Show overlay
var data = worldOverlayData;
var bounds = L.latLngBounds(
[data.min_lat, data.min_lon],
[data.max_lat, data.max_lon]
);
if (worldOverlay) {
map.removeLayer(worldOverlay);
}
var opacity = document.getElementById('world-preview-opacity');
var opacityValue = opacity ? opacity.value / 100 : 0.5;
worldOverlay = L.imageOverlay(data.image_base64, bounds, {
opacity: opacityValue,
interactive: false,
zIndex: 500
});
worldOverlay.addTo(map);
if (btn) {
btn.classList.add('active');
btn.title = 'Hide World Preview';
}
if (sliderContainer) {
sliderContainer.style.display = 'block';
}
} else {
// Hide overlay
if (worldOverlay) {
map.removeLayer(worldOverlay);
worldOverlay = null;
}
if (btn) {
btn.classList.remove('active');
btn.title = 'Show World Preview';
}
if (sliderContainer) {
sliderContainer.style.display = 'none';
}
}
}
// Enable the preview button when data is available
function enableWorldPreview(data) {
worldOverlayData = data;
worldPreviewAvailable = true;
var btn = document.getElementById('world-preview-btn');
if (btn) {
btn.classList.remove('disabled');
btn.title = 'Show World Preview';
}
}
// Disable and reset preview (when world changes)
function disableWorldPreview() {
worldPreviewAvailable = false;
worldOverlayData = null;
worldOverlayEnabled = false;
if (worldOverlay) {
map.removeLayer(worldOverlay);
worldOverlay = null;
}
var btn = document.getElementById('world-preview-btn');
var sliderContainer = document.getElementById('world-preview-slider-container');
if (btn) {
btn.classList.add('disabled');
btn.classList.remove('active');
btn.title = 'Show World Preview (not available yet)';
}
if (sliderContainer) {
sliderContainer.style.display = 'none';
}
}
// Listen for messages from parent window
window.addEventListener('message', function(event) {
if (event.data && event.data.type === 'changeTileTheme') {
changeTileTheme(event.data.theme);
}
// Handle world preview data ready (after generation completes)
if (event.data && event.data.type === 'worldPreviewReady') {
enableWorldPreview(event.data.data);
// Auto-enable the overlay when generation completes
if (!worldOverlayEnabled) {
toggleWorldOverlay();
}
}
// Handle existing world map load (zoom to location and auto-enable)
if (event.data && event.data.type === 'loadExistingWorldMap') {
var data = event.data.data;
enableWorldPreview(data);
// Calculate bounds and zoom to them
var bounds = L.latLngBounds(
[data.min_lat, data.min_lon],
[data.max_lat, data.max_lon]
);
map.fitBounds(bounds, { padding: [50, 50] });
// Auto-enable the overlay
if (!worldOverlayEnabled) {
toggleWorldOverlay();
}
}
// Handle world changed (disable preview)
if (event.data && event.data.type === 'worldChanged') {
disableWorldPreview();
}
});
// Set the dropdown value in parent window if it exists
@@ -652,6 +849,9 @@ $(document).ready(function () {
}
});
map.addControl(drawControl);
// Add world preview button to the edit toolbar after drawControl is added
addWorldPreviewToEditToolbar();
/*
**
** create bounds layer

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

@@ -214,6 +214,12 @@ function setupProgressListener() {
}
}
});
// Listen for map preview ready event from backend
window.__TAURI__.event.listen("map-preview-ready", () => {
console.log("Map preview ready event received");
showWorldPreviewButton();
});
}
function initSettings() {
@@ -591,6 +597,14 @@ async function selectWorld(generate_new_world) {
const lastSegment = worldName.split(/[\\/]/).pop();
document.getElementById('selected-world').textContent = lastSegment;
document.getElementById('selected-world').style.color = "#fecc44";
// Notify that world changed (reset preview)
notifyWorldChanged();
// If selecting an existing world, check for existing map data
if (!generate_new_world) {
await loadExistingWorldMapData();
}
}
} catch (error) {
handleWorldSelectionError(error);
@@ -599,6 +613,32 @@ async function selectWorld(generate_new_world) {
closeWorldPicker();
}
/**
* Loads existing world map data if available (for existing worlds)
* This will zoom to the location and auto-enable the preview
*/
async function loadExistingWorldMapData() {
if (!worldPath) return;
try {
const mapData = await invoke('gui_get_world_map_data', { worldPath: worldPath });
if (mapData) {
currentWorldMapData = mapData;
// Send data to the map iframe with instruction to zoom and auto-enable
const mapFrame = document.querySelector('.map-container');
if (mapFrame && mapFrame.contentWindow) {
mapFrame.contentWindow.postMessage({
type: 'loadExistingWorldMap',
data: mapData
}, '*');
}
}
} catch (error) {
console.log("No existing world map data found:", error);
}
}
/**
* Handles world selection errors and displays appropriate messages
* @param {number} errorCode - Error code from the backend
@@ -645,6 +685,9 @@ async function startGeneration() {
return;
}
// Clear any existing world preview since we're generating a new one
notifyWorldChanged();
// Get the map iframe reference
const mapFrame = document.querySelector('.map-container');
// Get spawn point coordinates if marker exists
@@ -702,3 +745,60 @@ async function startGeneration() {
generationButtonEnabled = true;
}
}
// World preview overlay state
let worldPreviewEnabled = false;
let currentWorldMapData = null;
/**
* Notifies the map iframe that world preview data is ready
* Called when the backend emits the map-preview-ready event
*/
async function showWorldPreviewButton() {
// Try to load the world map data
await loadWorldMapData();
if (currentWorldMapData) {
// Send data to the map iframe
const mapFrame = document.querySelector('.map-container');
if (mapFrame && mapFrame.contentWindow) {
mapFrame.contentWindow.postMessage({
type: 'worldPreviewReady',
data: currentWorldMapData
}, '*');
console.log("World preview data sent to map iframe");
}
} else {
console.warn("Map data not available yet");
}
}
/**
* Notifies the map iframe that the world has changed (reset preview)
*/
function notifyWorldChanged() {
currentWorldMapData = null;
const mapFrame = document.querySelector('.map-container');
if (mapFrame && mapFrame.contentWindow) {
mapFrame.contentWindow.postMessage({
type: 'worldChanged'
}, '*');
}
}
/**
* Loads the world map data from the backend
*/
async function loadWorldMapData() {
if (!worldPath) return;
try {
const mapData = await invoke('gui_get_world_map_data', { worldPath: worldPath });
if (mapData) {
currentWorldMapData = mapData;
console.log("World map data loaded successfully");
}
} catch (error) {
console.error("Failed to load world map data:", error);
}
}

View File

@@ -3,12 +3,15 @@
mod args;
mod block_definitions;
mod bresenham;
mod clipping;
mod colors;
mod coordinate_system;
mod data_processing;
mod element_processing;
mod elevation_data;
mod floodfill;
mod ground;
mod map_renderer;
mod map_transformation;
mod osm_parser;
#[cfg(feature = "gui")]
@@ -26,7 +29,6 @@ use clap::Parser;
use colored::*;
use std::{env, fs, io::Write};
mod elevation_data;
#[cfg(feature = "gui")]
mod gui;
@@ -35,6 +37,7 @@ mod gui;
mod progress {
pub fn emit_gui_error(_message: &str) {}
pub fn emit_gui_progress_update(_progress: f64, _message: &str) {}
pub fn emit_map_preview_ready() {}
pub fn is_running_with_gui() -> bool {
false
}

944
src/map_renderer.rs Normal file
View File

@@ -0,0 +1,944 @@
// Top-down world map renderer for GUI preview.
//
// Generates a 1:1 pixel-per-block PNG image of the generated world,
// showing the topmost visible block at each position.
use fastanvil::Region;
use fastnbt::{from_bytes, Value};
use fnv::FnvHashMap;
use image::{Rgb, RgbImage};
use once_cell::sync::Lazy;
use rayon::prelude::*;
use std::fs::File;
use std::path::Path;
use std::sync::Mutex;
/// Pre-computed block colors for fast lookup
static BLOCK_COLORS: Lazy<FnvHashMap<&'static str, Rgb<u8>>> = Lazy::new(get_block_colors);
/// Renders a top-down view of the generated Minecraft world.
/// Returns the path to the saved image file.
pub fn render_world_map(
world_dir: &Path,
min_x: i32,
max_x: i32,
min_z: i32,
max_z: i32,
) -> Result<std::path::PathBuf, String> {
let width = (max_x - min_x + 1) as u32;
let height = (max_z - min_z + 1) as u32;
if width == 0 || height == 0 {
return Err("Invalid world bounds".to_string());
}
// Use Mutex for thread-safe image access
let img = Mutex::new(RgbImage::from_pixel(width, height, Rgb([255, 255, 255])));
// Calculate region range
let min_region_x = min_x >> 9; // divide by 512 (32 chunks * 16 blocks)
let max_region_x = max_x >> 9;
let min_region_z = min_z >> 9;
let max_region_z = max_z >> 9;
let region_dir = world_dir.join("region");
// Collect all region coordinates for parallel processing
let region_coords: Vec<(i32, i32)> = (min_region_x..=max_region_x)
.flat_map(|rx| (min_region_z..=max_region_z).map(move |rz| (rx, rz)))
.collect();
// Process regions in parallel
region_coords.par_iter().for_each(|&(region_x, region_z)| {
let region_path = region_dir.join(format!("r.{}.{}.mca", region_x, region_z));
if !region_path.exists() {
return;
}
if let Ok(file) = File::open(&region_path) {
if let Ok(mut region) = Region::from_stream(file) {
// Collect all pixels from this region first
let pixels = render_region_to_pixels(
&mut region,
region_x,
region_z,
min_x,
min_z,
max_x,
max_z,
);
// Then batch-write to image under lock
if !pixels.is_empty() {
let mut img_guard = img.lock().unwrap();
for (x, z, color) in pixels {
if x < img_guard.width() && z < img_guard.height() {
img_guard.put_pixel(x, z, color);
}
}
}
}
}
});
// Save the image
let output_path = world_dir.join("arnis_world_map.png");
img.into_inner()
.unwrap()
.save(&output_path)
.map_err(|e| format!("Failed to save map image: {}", e))?;
Ok(output_path)
}
/// Renders all chunks within a region and returns pixel data
fn render_region_to_pixels(
region: &mut Region<File>,
region_x: i32,
region_z: i32,
min_x: i32,
min_z: i32,
max_x: i32,
max_z: i32,
) -> Vec<(u32, u32, Rgb<u8>)> {
let mut pixels = Vec::new();
let region_base_x = region_x * 512;
let region_base_z = region_z * 512;
for chunk_local_x in 0..32 {
for chunk_local_z in 0..32 {
let chunk_base_x = region_base_x + chunk_local_x * 16;
let chunk_base_z = region_base_z + chunk_local_z * 16;
// Skip chunks outside our bounds
if chunk_base_x + 15 < min_x
|| chunk_base_x > max_x
|| chunk_base_z + 15 < min_z
|| chunk_base_z > max_z
{
continue;
}
if let Ok(Some(chunk_data)) =
region.read_chunk(chunk_local_x as usize, chunk_local_z as usize)
{
render_chunk_to_pixels(
&chunk_data,
&mut pixels,
chunk_base_x,
chunk_base_z,
min_x,
min_z,
max_x,
max_z,
);
}
}
}
pixels
}
/// Renders a single chunk and appends pixel data
#[allow(clippy::too_many_arguments)]
fn render_chunk_to_pixels(
chunk_data: &[u8],
pixels: &mut Vec<(u32, u32, Rgb<u8>)>,
chunk_base_x: i32,
chunk_base_z: i32,
min_x: i32,
min_z: i32,
max_x: i32,
max_z: i32,
) {
// Parse chunk NBT - look for Level.sections or sections depending on format
let chunk: Value = match from_bytes(chunk_data) {
Ok(v) => v,
Err(_) => return,
};
// Try to get sections from the chunk data
let sections = get_sections_from_chunk(&chunk);
if sections.is_empty() {
return;
}
// Pre-sort sections by Y (descending) once per chunk, not per column
let sorted_sections = get_sorted_sections(&sections);
if sorted_sections.is_empty() {
return;
}
// For each column in the chunk
for local_x in 0..16 {
for local_z in 0..16 {
let world_x = chunk_base_x + local_x;
let world_z = chunk_base_z + local_z;
// Skip if outside our bounds
if world_x < min_x || world_x > max_x || world_z < min_z || world_z > max_z {
continue;
}
// Find topmost non-air block using pre-sorted sections
if let Some((block_name, world_y)) =
find_top_block_sorted(&sorted_sections, local_x as usize, local_z as usize)
{
// Strip minecraft: prefix for lookup
let short_name = block_name.strip_prefix("minecraft:").unwrap_or(&block_name);
let base_color = BLOCK_COLORS
.get(short_name)
.copied()
.unwrap_or_else(|| get_fallback_color(&block_name));
// Apply elevation shading
let color = apply_elevation_shading(base_color, world_y);
let img_x = (world_x - min_x) as u32;
let img_z = (world_z - min_z) as u32;
pixels.push((img_x, img_z, color));
}
}
}
}
/// Applies elevation-based shading to a color
/// Higher elevations are brighter, lower are darker
#[inline]
fn apply_elevation_shading(color: Rgb<u8>, y: i32) -> Rgb<u8> {
// Base brightness boost of 10%, plus elevation shading
// Shading range: -20% darker to +20% brighter (asymmetric, more bright than dark)
// Normalize Y to a -1.0 to 1.0 range (roughly)
// y=0 -> -0.5, y=0 -> 0, y=200 -> +1.0
let normalized = (y as f32 / 100.0).clamp(-1.0, 1.0);
// Base 10% brightness boost + asymmetric elevation shading
let elevation_adjust = if normalized >= 0.0 {
// Above sea level: up to +20% brighter
normalized * 0.20
} else {
// Below sea level: up to -20% darker
normalized * 0.20
};
let multiplier = 1.10 + elevation_adjust;
Rgb([
(color.0[0] as f32 * multiplier).clamp(0.0, 255.0) as u8,
(color.0[1] as f32 * multiplier).clamp(0.0, 255.0) as u8,
(color.0[2] as f32 * multiplier).clamp(0.0, 255.0) as u8,
])
}
/// Extracts sections from chunk data (handles both old and new formats)
fn get_sections_from_chunk(chunk: &Value) -> Vec<&Value> {
let mut sections = Vec::new();
// Try new format (1.18+): directly in chunk
if let Value::Compound(map) = chunk {
if let Some(Value::List(secs)) = map.get("sections") {
for sec in secs {
sections.push(sec);
}
return sections;
}
// Try via Level wrapper (older format)
if let Some(Value::Compound(level)) = map.get("Level") {
if let Some(Value::List(secs)) = level.get("sections") {
for sec in secs {
sections.push(sec);
}
}
}
}
sections
}
/// Pre-sorts sections by Y coordinate (descending) - called once per chunk
/// Returns Vec of (section_y, section_value) for Y tracking
fn get_sorted_sections<'a>(sections: &[&'a Value]) -> Vec<(i8, &'a Value)> {
let mut sorted: Vec<(i8, &Value)> = sections
.iter()
.filter_map(|s| {
if let Value::Compound(map) = s {
if let Some(Value::Byte(y)) = map.get("Y") {
return Some((*y, *s));
}
}
None
})
.collect();
sorted.sort_by(|a, b| b.0.cmp(&a.0));
sorted
}
/// Finds the topmost non-air block using pre-sorted sections
/// Returns (block_name, world_y) where world_y is the actual Y coordinate
fn find_top_block_sorted(
sorted_sections: &[(i8, &Value)],
local_x: usize,
local_z: usize,
) -> Option<(String, i32)> {
for (section_y, section) in sorted_sections {
if let Some((block_name, local_y)) = get_block_at_section(section, local_x, local_z) {
if !is_transparent_block(&block_name) {
// Calculate world Y: section_y * 16 + local_y
let world_y = (*section_y as i32) * 16 + local_y as i32;
return Some((block_name, world_y));
}
}
}
None
}
/// Gets the topmost non-air block in a section at the given x,z
/// Returns (block_name, local_y) where local_y is 0-15 within the section
fn get_block_at_section(
section: &Value,
local_x: usize,
local_z: usize,
) -> Option<(String, usize)> {
let section_map = match section {
Value::Compound(m) => m,
_ => return None,
};
let block_states = match section_map.get("block_states") {
Some(Value::Compound(bs)) => bs,
_ => return None,
};
let palette = match block_states.get("palette") {
Some(Value::List(p)) => p,
_ => return None,
};
// If palette has only one block, that's the block for the entire section
if palette.len() == 1 {
// Return with local_y=15 (top of section) for single-block sections
return get_block_name_from_palette(&palette[0]).map(|name| (name, 15));
}
let data = match block_states.get("data") {
Some(Value::LongArray(d)) => d,
_ => return None,
};
// Calculate bits per block
let bits_per_block = std::cmp::max(4, (palette.len() as f64).log2().ceil() as usize);
let blocks_per_long = 64 / bits_per_block;
let mask = (1u64 << bits_per_block) - 1;
// Search from top (y=15) to bottom (y=0) within this section
for local_y in (0..16).rev() {
let block_index = local_y * 256 + local_z * 16 + local_x;
let long_index = block_index / blocks_per_long;
let bit_offset = (block_index % blocks_per_long) * bits_per_block;
if long_index >= data.len() {
continue;
}
let palette_index = ((data[long_index] as u64 >> bit_offset) & mask) as usize;
if palette_index < palette.len() {
if let Some(name) = get_block_name_from_palette(&palette[palette_index]) {
if !is_transparent_block(&name) {
return Some((name, local_y));
}
}
}
}
None
}
/// Extracts block name from a palette entry
fn get_block_name_from_palette(entry: &Value) -> Option<String> {
if let Value::Compound(map) = entry {
if let Some(Value::String(name)) = map.get("Name") {
return Some(name.clone());
}
}
None
}
/// Checks if a block should be considered transparent (look through it)
fn is_transparent_block(name: &str) -> bool {
let short_name = name.strip_prefix("minecraft:").unwrap_or(name);
matches!(
short_name,
"air"
| "cave_air"
| "void_air"
| "glass"
| "glass_pane"
| "white_stained_glass"
| "gray_stained_glass"
| "light_gray_stained_glass"
| "brown_stained_glass"
| "tinted_glass"
| "barrier"
| "light"
| "short_grass"
| "tall_grass"
| "dead_bush"
| "poppy"
| "dandelion"
| "blue_orchid"
| "azure_bluet"
| "iron_bars"
| "ladder"
| "scaffolding"
| "rail"
| "powered_rail"
| "detector_rail"
| "activator_rail"
)
}
/// Returns a fallback color based on block name patterns
fn get_fallback_color(name: &str) -> Rgb<u8> {
// Try to guess color from name
if name.contains("stone") || name.contains("cobble") || name.contains("andesite") {
return Rgb([128, 128, 128]);
}
if name.contains("dirt") || name.contains("mud") {
return Rgb([139, 90, 43]);
}
if name.contains("sand") {
return Rgb([219, 211, 160]);
}
if name.contains("grass") {
return Rgb([86, 125, 70]);
}
if name.contains("water") {
return Rgb([59, 86, 165]);
}
if name.contains("log") || name.contains("wood") {
return Rgb([101, 76, 48]);
}
if name.contains("leaves") {
return Rgb([55, 95, 36]);
}
if name.contains("planks") {
return Rgb([162, 130, 78]);
}
if name.contains("brick") {
return Rgb([150, 97, 83]);
}
if name.contains("concrete") {
return Rgb([128, 128, 128]);
}
if name.contains("wool") || name.contains("carpet") {
return Rgb([220, 220, 220]);
}
if name.contains("terracotta") {
return Rgb([152, 94, 67]);
}
if name.contains("iron") {
return Rgb([200, 200, 200]);
}
if name.contains("gold") {
return Rgb([255, 215, 0]);
}
if name.contains("diamond") {
return Rgb([97, 219, 213]);
}
if name.contains("emerald") {
return Rgb([17, 160, 54]);
}
if name.contains("lapis") {
return Rgb([38, 67, 156]);
}
if name.contains("redstone") {
return Rgb([170, 0, 0]);
}
if name.contains("netherrack") || name.contains("nether") {
return Rgb([111, 54, 53]);
}
if name.contains("end_stone") {
return Rgb([219, 222, 158]);
}
if name.contains("obsidian") {
return Rgb([15, 10, 24]);
}
if name.contains("deepslate") {
return Rgb([72, 72, 73]);
}
if name.contains("blackstone") {
return Rgb([42, 36, 41]);
}
if name.contains("quartz") {
return Rgb([235, 229, 222]);
}
if name.contains("prismarine") {
return Rgb([76, 128, 113]);
}
if name.contains("copper") {
return Rgb([192, 107, 79]);
}
if name.contains("amethyst") {
return Rgb([133, 97, 191]);
}
if name.contains("moss") {
return Rgb([89, 109, 45]);
}
if name.contains("dripstone") {
return Rgb([134, 107, 92]);
}
// Default gray for unknown blocks
Rgb([160, 160, 160])
}
/// Returns a mapping of common block names to RGB colors (without minecraft: prefix)
fn get_block_colors() -> FnvHashMap<&'static str, Rgb<u8>> {
FnvHashMap::from_iter([
("grass_block", Rgb([86, 125, 70])),
("short_grass", Rgb([86, 125, 70])),
("tall_grass", Rgb([86, 125, 70])),
("dirt", Rgb([139, 90, 43])),
("coarse_dirt", Rgb([119, 85, 59])),
("podzol", Rgb([91, 63, 24])),
("rooted_dirt", Rgb([144, 103, 76])),
("mud", Rgb([60, 57, 61])),
("stone", Rgb([128, 128, 128])),
("granite", Rgb([149, 108, 91])),
("polished_granite", Rgb([154, 112, 98])),
("diorite", Rgb([189, 188, 189])),
("polished_diorite", Rgb([195, 195, 195])),
("andesite", Rgb([136, 136, 137])),
("polished_andesite", Rgb([132, 135, 134])),
("deepslate", Rgb([72, 72, 73])),
("cobbled_deepslate", Rgb([77, 77, 80])),
("polished_deepslate", Rgb([72, 72, 73])),
("deepslate_bricks", Rgb([70, 70, 71])),
("deepslate_tiles", Rgb([54, 54, 55])),
("calcite", Rgb([223, 224, 220])),
("tuff", Rgb([108, 109, 102])),
("dripstone_block", Rgb([134, 107, 92])),
("sand", Rgb([219, 211, 160])),
("red_sand", Rgb([190, 102, 33])),
("gravel", Rgb([131, 127, 126])),
("clay", Rgb([160, 166, 179])),
("bedrock", Rgb([85, 85, 85])),
("water", Rgb([59, 86, 165])),
("ice", Rgb([145, 183, 253])),
("packed_ice", Rgb([141, 180, 250])),
("blue_ice", Rgb([116, 167, 253])),
("snow", Rgb([249, 254, 254])),
("snow_block", Rgb([249, 254, 254])),
("powder_snow", Rgb([248, 253, 253])),
("oak_log", Rgb([109, 85, 50])),
("oak_planks", Rgb([162, 130, 78])),
("oak_slab", Rgb([162, 130, 78])),
("oak_stairs", Rgb([162, 130, 78])),
("oak_fence", Rgb([162, 130, 78])),
("oak_door", Rgb([162, 130, 78])),
("spruce_log", Rgb([58, 37, 16])),
("spruce_planks", Rgb([115, 85, 49])),
("spruce_slab", Rgb([115, 85, 49])),
("spruce_stairs", Rgb([115, 85, 49])),
("spruce_fence", Rgb([115, 85, 49])),
("spruce_door", Rgb([115, 85, 49])),
("birch_log", Rgb([216, 215, 210])),
("birch_planks", Rgb([196, 179, 123])),
("birch_slab", Rgb([196, 179, 123])),
("birch_stairs", Rgb([196, 179, 123])),
("birch_fence", Rgb([196, 179, 123])),
("birch_door", Rgb([196, 179, 123])),
("jungle_log", Rgb([85, 68, 25])),
("jungle_planks", Rgb([160, 115, 81])),
("acacia_log", Rgb([103, 96, 86])),
("acacia_planks", Rgb([168, 90, 50])),
("dark_oak_log", Rgb([60, 46, 26])),
("dark_oak_planks", Rgb([67, 43, 20])),
("dark_oak_slab", Rgb([67, 43, 20])),
("dark_oak_stairs", Rgb([67, 43, 20])),
("dark_oak_fence", Rgb([67, 43, 20])),
("dark_oak_door", Rgb([67, 43, 20])),
("mangrove_log", Rgb([84, 66, 36])),
("mangrove_planks", Rgb([117, 54, 48])),
("cherry_log", Rgb([54, 33, 44])),
("cherry_planks", Rgb([226, 178, 172])),
("bamboo_block", Rgb([122, 129, 52])),
("bamboo_planks", Rgb([194, 175, 93])),
("crimson_stem", Rgb([92, 25, 29])),
("crimson_planks", Rgb([101, 48, 70])),
("warped_stem", Rgb([58, 58, 77])),
("warped_planks", Rgb([43, 104, 99])),
("oak_leaves", Rgb([55, 95, 36])),
("spruce_leaves", Rgb([61, 99, 61])),
("birch_leaves", Rgb([80, 106, 47])),
("jungle_leaves", Rgb([48, 113, 20])),
("acacia_leaves", Rgb([75, 104, 40])),
("dark_oak_leaves", Rgb([35, 82, 11])),
("mangrove_leaves", Rgb([69, 123, 38])),
("cherry_leaves", Rgb([228, 177, 197])),
("azalea_leaves", Rgb([71, 96, 37])),
("stone_bricks", Rgb([122, 122, 122])),
("stone_brick_slab", Rgb([122, 122, 122])),
("stone_brick_stairs", Rgb([122, 122, 122])),
("stone_brick_wall", Rgb([122, 122, 122])),
("mossy_stone_bricks", Rgb([115, 121, 105])),
("mossy_stone_brick_slab", Rgb([115, 121, 105])),
("mossy_stone_brick_stairs", Rgb([115, 121, 105])),
("mossy_stone_brick_wall", Rgb([115, 121, 105])),
("cracked_stone_bricks", Rgb([118, 117, 118])),
("chiseled_stone_bricks", Rgb([119, 119, 119])),
("cobblestone", Rgb([128, 127, 127])),
("cobblestone_slab", Rgb([128, 127, 127])),
("cobblestone_stairs", Rgb([128, 127, 127])),
("cobblestone_wall", Rgb([128, 127, 127])),
("mossy_cobblestone", Rgb([110, 118, 94])),
("mossy_cobblestone_slab", Rgb([110, 118, 94])),
("mossy_cobblestone_stairs", Rgb([110, 118, 94])),
("mossy_cobblestone_wall", Rgb([110, 118, 94])),
("stone_slab", Rgb([128, 128, 128])),
("stone_stairs", Rgb([128, 128, 128])),
("smooth_stone", Rgb([158, 158, 158])),
("smooth_stone_slab", Rgb([158, 158, 158])),
("bricks", Rgb([150, 97, 83])),
("brick_slab", Rgb([150, 97, 83])),
("brick_stairs", Rgb([150, 97, 83])),
("brick_wall", Rgb([150, 97, 83])),
("mud_bricks", Rgb([137, 103, 79])),
("mud_brick_slab", Rgb([137, 103, 79])),
("mud_brick_stairs", Rgb([137, 103, 79])),
("mud_brick_wall", Rgb([137, 103, 79])),
("terracotta", Rgb([152, 94, 67])),
("white_terracotta", Rgb([210, 178, 161])),
("orange_terracotta", Rgb([162, 84, 38])),
("magenta_terracotta", Rgb([149, 88, 109])),
("light_blue_terracotta", Rgb([113, 109, 138])),
("yellow_terracotta", Rgb([186, 133, 35])),
("lime_terracotta", Rgb([104, 118, 53])),
("pink_terracotta", Rgb([162, 78, 79])),
("gray_terracotta", Rgb([58, 42, 36])),
("light_gray_terracotta", Rgb([135, 107, 98])),
("cyan_terracotta", Rgb([87, 91, 91])),
("purple_terracotta", Rgb([118, 70, 86])),
("blue_terracotta", Rgb([74, 60, 91])),
("brown_terracotta", Rgb([77, 51, 36])),
("green_terracotta", Rgb([76, 83, 42])),
("red_terracotta", Rgb([143, 61, 47])),
("black_terracotta", Rgb([37, 23, 16])),
("white_concrete", Rgb([207, 213, 214])),
("orange_concrete", Rgb([224, 97, 0])),
("magenta_concrete", Rgb([169, 48, 159])),
("light_blue_concrete", Rgb([35, 137, 198])),
("yellow_concrete", Rgb([241, 175, 21])),
("lime_concrete", Rgb([94, 169, 24])),
("pink_concrete", Rgb([214, 101, 143])),
("gray_concrete", Rgb([55, 58, 62])),
("light_gray_concrete", Rgb([125, 125, 115])),
("cyan_concrete", Rgb([21, 119, 136])),
("purple_concrete", Rgb([100, 32, 156])),
("blue_concrete", Rgb([45, 47, 143])),
("brown_concrete", Rgb([96, 60, 32])),
("green_concrete", Rgb([73, 91, 36])),
("red_concrete", Rgb([142, 33, 33])),
("black_concrete", Rgb([8, 10, 15])),
("white_wool", Rgb([234, 236, 237])),
("orange_wool", Rgb([241, 118, 20])),
("magenta_wool", Rgb([190, 68, 179])),
("light_blue_wool", Rgb([58, 175, 217])),
("yellow_wool", Rgb([249, 198, 40])),
("lime_wool", Rgb([112, 185, 26])),
("pink_wool", Rgb([238, 141, 172])),
("gray_wool", Rgb([63, 68, 72])),
("light_gray_wool", Rgb([142, 142, 135])),
("cyan_wool", Rgb([21, 138, 145])),
("purple_wool", Rgb([122, 42, 173])),
("blue_wool", Rgb([53, 57, 157])),
("brown_wool", Rgb([114, 72, 41])),
("green_wool", Rgb([85, 110, 28])),
("red_wool", Rgb([161, 39, 35])),
("black_wool", Rgb([21, 21, 26])),
("sandstone", Rgb([223, 214, 170])),
("sandstone_slab", Rgb([223, 214, 170])),
("sandstone_stairs", Rgb([223, 214, 170])),
("sandstone_wall", Rgb([223, 214, 170])),
("chiseled_sandstone", Rgb([223, 214, 170])),
("cut_sandstone", Rgb([225, 217, 171])),
("cut_sandstone_slab", Rgb([225, 217, 171])),
("smooth_sandstone", Rgb([223, 214, 170])),
("smooth_sandstone_slab", Rgb([223, 214, 170])),
("smooth_sandstone_stairs", Rgb([223, 214, 170])),
("red_sandstone", Rgb([186, 99, 29])),
("red_sandstone_slab", Rgb([186, 99, 29])),
("red_sandstone_stairs", Rgb([186, 99, 29])),
("red_sandstone_wall", Rgb([186, 99, 29])),
("smooth_red_sandstone", Rgb([186, 99, 29])),
("netherrack", Rgb([111, 54, 53])),
("nether_bricks", Rgb([44, 21, 26])),
("nether_brick_slab", Rgb([44, 21, 26])),
("nether_brick_stairs", Rgb([44, 21, 26])),
("nether_brick_wall", Rgb([44, 21, 26])),
("nether_brick_fence", Rgb([44, 21, 26])),
("red_nether_bricks", Rgb([69, 7, 9])),
("red_nether_brick_slab", Rgb([69, 7, 9])),
("red_nether_brick_stairs", Rgb([69, 7, 9])),
("red_nether_brick_wall", Rgb([69, 7, 9])),
("soul_sand", Rgb([81, 62, 51])),
("soul_soil", Rgb([75, 57, 46])),
("basalt", Rgb([73, 72, 77])),
("polished_basalt", Rgb([88, 87, 91])),
("smooth_basalt", Rgb([72, 72, 78])),
("blackstone", Rgb([42, 36, 41])),
("blackstone_slab", Rgb([42, 36, 41])),
("blackstone_stairs", Rgb([42, 36, 41])),
("blackstone_wall", Rgb([42, 36, 41])),
("polished_blackstone", Rgb([53, 49, 56])),
("polished_blackstone_bricks", Rgb([48, 43, 50])),
("polished_blackstone_brick_slab", Rgb([48, 43, 50])),
("polished_blackstone_brick_stairs", Rgb([48, 43, 50])),
("polished_blackstone_brick_wall", Rgb([48, 43, 50])),
("glowstone", Rgb([171, 131, 84])),
("shroomlight", Rgb([240, 146, 70])),
("crying_obsidian", Rgb([32, 10, 60])),
("obsidian", Rgb([15, 10, 24])),
("end_stone", Rgb([219, 222, 158])),
("end_stone_bricks", Rgb([218, 224, 162])),
("end_stone_brick_slab", Rgb([218, 224, 162])),
("end_stone_brick_stairs", Rgb([218, 224, 162])),
("end_stone_brick_wall", Rgb([218, 224, 162])),
("purpur_block", Rgb([170, 126, 170])),
("purpur_pillar", Rgb([171, 129, 171])),
("purpur_slab", Rgb([170, 126, 170])),
("purpur_stairs", Rgb([170, 126, 170])),
("coal_ore", Rgb([105, 105, 105])),
("iron_ore", Rgb([136, 130, 127])),
("copper_ore", Rgb([124, 125, 120])),
("gold_ore", Rgb([143, 140, 125])),
("redstone_ore", Rgb([133, 107, 107])),
("emerald_ore", Rgb([108, 136, 115])),
("lapis_ore", Rgb([99, 112, 135])),
("diamond_ore", Rgb([121, 141, 140])),
("coal_block", Rgb([16, 15, 15])),
("iron_block", Rgb([220, 220, 220])),
("copper_block", Rgb([192, 107, 79])),
("gold_block", Rgb([246, 208, 62])),
("redstone_block", Rgb([170, 0, 0])),
("emerald_block", Rgb([42, 203, 88])),
("lapis_block", Rgb([38, 67, 156])),
("diamond_block", Rgb([97, 219, 213])),
("netherite_block", Rgb([66, 61, 63])),
("amethyst_block", Rgb([133, 97, 191])),
("raw_iron_block", Rgb([166, 136, 107])),
("raw_copper_block", Rgb([154, 105, 79])),
("raw_gold_block", Rgb([221, 169, 46])),
("quartz_block", Rgb([235, 229, 222])),
("quartz_slab", Rgb([235, 229, 222])),
("quartz_stairs", Rgb([235, 229, 222])),
("smooth_quartz", Rgb([235, 229, 222])),
("smooth_quartz_slab", Rgb([235, 229, 222])),
("smooth_quartz_stairs", Rgb([235, 229, 222])),
("quartz_bricks", Rgb([234, 229, 221])),
("quartz_pillar", Rgb([235, 230, 224])),
("chiseled_quartz_block", Rgb([231, 226, 218])),
("prismarine", Rgb([76, 128, 113])),
("prismarine_slab", Rgb([76, 128, 113])),
("prismarine_stairs", Rgb([76, 128, 113])),
("prismarine_wall", Rgb([76, 128, 113])),
("prismarine_bricks", Rgb([99, 172, 158])),
("prismarine_brick_slab", Rgb([99, 172, 158])),
("prismarine_brick_stairs", Rgb([99, 172, 158])),
("dark_prismarine", Rgb([51, 91, 75])),
("dark_prismarine_slab", Rgb([51, 91, 75])),
("dark_prismarine_stairs", Rgb([51, 91, 75])),
("sea_lantern", Rgb([172, 199, 190])),
("exposed_copper", Rgb([161, 125, 103])),
("weathered_copper", Rgb([109, 145, 107])),
("oxidized_copper", Rgb([82, 162, 132])),
("cut_copper", Rgb([191, 106, 80])),
("cut_copper_slab", Rgb([191, 106, 80])),
("cut_copper_stairs", Rgb([191, 106, 80])),
("exposed_cut_copper", Rgb([154, 121, 101])),
("exposed_cut_copper_slab", Rgb([154, 121, 101])),
("exposed_cut_copper_stairs", Rgb([154, 121, 101])),
("weathered_cut_copper", Rgb([109, 145, 107])),
("weathered_cut_copper_slab", Rgb([109, 145, 107])),
("weathered_cut_copper_stairs", Rgb([109, 145, 107])),
("oxidized_cut_copper", Rgb([79, 153, 126])),
("oxidized_cut_copper_slab", Rgb([79, 153, 126])),
("oxidized_cut_copper_stairs", Rgb([79, 153, 126])),
("glass", Rgb([200, 220, 230])),
("glass_pane", Rgb([200, 220, 230])),
("white_stained_glass", Rgb([255, 255, 255])),
("white_stained_glass_pane", Rgb([255, 255, 255])),
("orange_stained_glass", Rgb([216, 127, 51])),
("orange_stained_glass_pane", Rgb([216, 127, 51])),
("magenta_stained_glass", Rgb([178, 76, 216])),
("magenta_stained_glass_pane", Rgb([178, 76, 216])),
("light_blue_stained_glass", Rgb([102, 153, 216])),
("light_blue_stained_glass_pane", Rgb([102, 153, 216])),
("yellow_stained_glass", Rgb([229, 229, 51])),
("yellow_stained_glass_pane", Rgb([229, 229, 51])),
("lime_stained_glass", Rgb([127, 204, 25])),
("lime_stained_glass_pane", Rgb([127, 204, 25])),
("pink_stained_glass", Rgb([242, 127, 165])),
("pink_stained_glass_pane", Rgb([242, 127, 165])),
("gray_stained_glass", Rgb([76, 76, 76])),
("gray_stained_glass_pane", Rgb([76, 76, 76])),
("light_gray_stained_glass", Rgb([153, 153, 153])),
("light_gray_stained_glass_pane", Rgb([153, 153, 153])),
("cyan_stained_glass", Rgb([76, 127, 153])),
("cyan_stained_glass_pane", Rgb([76, 127, 153])),
("purple_stained_glass", Rgb([127, 63, 178])),
("purple_stained_glass_pane", Rgb([127, 63, 178])),
("blue_stained_glass", Rgb([51, 76, 178])),
("blue_stained_glass_pane", Rgb([51, 76, 178])),
("brown_stained_glass", Rgb([102, 76, 51])),
("brown_stained_glass_pane", Rgb([102, 76, 51])),
("green_stained_glass", Rgb([102, 127, 51])),
("green_stained_glass_pane", Rgb([102, 127, 51])),
("red_stained_glass", Rgb([153, 51, 51])),
("red_stained_glass_pane", Rgb([153, 51, 51])),
("black_stained_glass", Rgb([25, 25, 25])),
("black_stained_glass_pane", Rgb([25, 25, 25])),
("bookshelf", Rgb([116, 89, 53])),
("hay_block", Rgb([166, 139, 12])),
("melon", Rgb([111, 145, 31])),
("pumpkin", Rgb([198, 118, 24])),
("jack_o_lantern", Rgb([213, 139, 42])),
("carved_pumpkin", Rgb([198, 118, 24])),
("tnt", Rgb([219, 68, 52])),
("sponge", Rgb([195, 192, 74])),
("wet_sponge", Rgb([171, 181, 70])),
("moss_block", Rgb([89, 109, 45])),
("moss_carpet", Rgb([89, 109, 45])),
("sculk", Rgb([12, 28, 36])),
("honeycomb_block", Rgb([229, 148, 29])),
("slime_block", Rgb([111, 192, 91])),
("honey_block", Rgb([251, 185, 52])),
("barrel", Rgb([140, 106, 60])),
("chest", Rgb([155, 113, 48])),
("trapped_chest", Rgb([155, 113, 48])),
("crafting_table", Rgb([144, 109, 67])),
("furnace", Rgb([110, 110, 110])),
("blast_furnace", Rgb([80, 80, 85])),
("smoker", Rgb([90, 80, 70])),
("anvil", Rgb([68, 68, 68])),
("lectern", Rgb([180, 140, 90])),
("composter", Rgb([100, 80, 45])),
("cauldron", Rgb([60, 60, 60])),
("hopper", Rgb([70, 70, 70])),
("jukebox", Rgb([130, 90, 70])),
("note_block", Rgb([120, 80, 65])),
("bell", Rgb([200, 170, 50])),
("dirt_path", Rgb([148, 121, 65])),
("farmland", Rgb([143, 88, 46])),
("mycelium", Rgb([111, 99, 107])),
("rail", Rgb([125, 108, 77])),
("powered_rail", Rgb([153, 126, 55])),
("detector_rail", Rgb([120, 97, 80])),
("activator_rail", Rgb([117, 85, 76])),
("redstone_wire", Rgb([170, 0, 0])),
("redstone_torch", Rgb([170, 0, 0])),
("redstone_lamp", Rgb([180, 130, 70])),
("lever", Rgb([100, 80, 60])),
("tripwire_hook", Rgb([120, 100, 80])),
("torch", Rgb([255, 200, 100])),
("wall_torch", Rgb([255, 200, 100])),
("lantern", Rgb([200, 150, 80])),
("soul_lantern", Rgb([80, 200, 200])),
("soul_torch", Rgb([80, 200, 200])),
("soul_wall_torch", Rgb([80, 200, 200])),
("campfire", Rgb([200, 100, 50])),
("soul_campfire", Rgb([80, 200, 200])),
("candle", Rgb([200, 180, 130])),
("dandelion", Rgb([255, 236, 85])),
("poppy", Rgb([200, 30, 30])),
("blue_orchid", Rgb([47, 186, 199])),
("allium", Rgb([190, 130, 200])),
("azure_bluet", Rgb([220, 230, 220])),
("red_tulip", Rgb([200, 50, 50])),
("orange_tulip", Rgb([230, 130, 50])),
("white_tulip", Rgb([230, 230, 220])),
("pink_tulip", Rgb([220, 150, 170])),
("oxeye_daisy", Rgb([230, 230, 200])),
("cornflower", Rgb([70, 90, 180])),
("lily_of_the_valley", Rgb([230, 230, 230])),
("wither_rose", Rgb([30, 30, 30])),
("sunflower", Rgb([255, 200, 50])),
("lilac", Rgb([200, 150, 200])),
("rose_bush", Rgb([180, 40, 40])),
("peony", Rgb([230, 180, 200])),
("fern", Rgb([80, 120, 60])),
("large_fern", Rgb([80, 120, 60])),
("dead_bush", Rgb([150, 120, 80])),
("seagrass", Rgb([40, 100, 60])),
("tall_seagrass", Rgb([40, 100, 60])),
("kelp", Rgb([50, 110, 60])),
("kelp_plant", Rgb([50, 110, 60])),
("sugar_cane", Rgb([140, 180, 100])),
("bamboo", Rgb([90, 140, 50])),
("vine", Rgb([50, 100, 40])),
("lily_pad", Rgb([40, 110, 40])),
("sweet_berry_bush", Rgb([60, 90, 50])),
("cactus", Rgb([85, 127, 52])),
("white_carpet", Rgb([234, 236, 237])),
("orange_carpet", Rgb([241, 118, 20])),
("magenta_carpet", Rgb([190, 68, 179])),
("light_blue_carpet", Rgb([58, 175, 217])),
("yellow_carpet", Rgb([249, 198, 40])),
("lime_carpet", Rgb([112, 185, 26])),
("pink_carpet", Rgb([238, 141, 172])),
("gray_carpet", Rgb([63, 68, 72])),
("light_gray_carpet", Rgb([142, 142, 135])),
("cyan_carpet", Rgb([21, 138, 145])),
("purple_carpet", Rgb([122, 42, 173])),
("blue_carpet", Rgb([53, 57, 157])),
("brown_carpet", Rgb([114, 72, 41])),
("green_carpet", Rgb([85, 110, 28])),
("red_carpet", Rgb([161, 39, 35])),
("black_carpet", Rgb([21, 21, 26])),
("oak_sign", Rgb([162, 130, 78])),
("oak_wall_sign", Rgb([162, 130, 78])),
("spruce_sign", Rgb([115, 85, 49])),
("spruce_wall_sign", Rgb([115, 85, 49])),
("birch_sign", Rgb([196, 179, 123])),
("birch_wall_sign", Rgb([196, 179, 123])),
("dark_oak_sign", Rgb([67, 43, 20])),
("dark_oak_wall_sign", Rgb([67, 43, 20])),
("white_bed", Rgb([234, 236, 237])),
("orange_bed", Rgb([241, 118, 20])),
("magenta_bed", Rgb([190, 68, 179])),
("light_blue_bed", Rgb([58, 175, 217])),
("yellow_bed", Rgb([249, 198, 40])),
("lime_bed", Rgb([112, 185, 26])),
("pink_bed", Rgb([238, 141, 172])),
("gray_bed", Rgb([63, 68, 72])),
("light_gray_bed", Rgb([142, 142, 135])),
("cyan_bed", Rgb([21, 138, 145])),
("purple_bed", Rgb([122, 42, 173])),
("blue_bed", Rgb([53, 57, 157])),
("brown_bed", Rgb([114, 72, 41])),
("green_bed", Rgb([85, 110, 28])),
("red_bed", Rgb([161, 39, 35])),
("black_bed", Rgb([21, 21, 26])),
("oak_trapdoor", Rgb([162, 130, 78])),
("spruce_trapdoor", Rgb([115, 85, 49])),
("birch_trapdoor", Rgb([196, 179, 123])),
("dark_oak_trapdoor", Rgb([67, 43, 20])),
("iron_trapdoor", Rgb([200, 200, 200])),
("iron_bars", Rgb([150, 150, 150])),
("ladder", Rgb([160, 130, 70])),
("wheat", Rgb([200, 180, 80])),
("carrots", Rgb([230, 140, 30])),
("potatoes", Rgb([180, 160, 80])),
("beetroots", Rgb([150, 50, 50])),
("pumpkin_stem", Rgb([120, 140, 70])),
("melon_stem", Rgb([120, 140, 70])),
])
}

View File

@@ -1,3 +1,4 @@
use crate::clipping::clip_way_to_bbox;
use crate::coordinate_system::cartesian::{XZBBox, XZPoint};
use crate::coordinate_system::geographic::{LLBBox, LLPoint};
use crate::coordinate_system::transformation::CoordTransformer;
@@ -211,7 +212,14 @@ pub fn parse_osm_data(
nodes_map.insert(element.id, processed.clone());
processed_elements.push(ProcessedElement::Node(processed));
// Only add tagged nodes to processed_elements if they're within or near the bbox
// This significantly improves performance by filtering out distant nodes
if !element.tags.as_ref().map(|t| t.is_empty()).unwrap_or(true) {
// Node has tags, check if it's in the bbox (with some margin)
if xzbbox.contains(&xzpoint) {
processed_elements.push(ProcessedElement::Node(processed));
}
}
}
}
@@ -226,13 +234,33 @@ pub fn parse_osm_data(
}
}
// Clip the way to bbox to reduce node count dramatically
let tags = element.tags.clone().unwrap_or_default();
// Store unclipped way for relation assembly (clipping happens after ring merging)
ways_map.insert(
element.id,
ProcessedWay {
id: element.id,
tags: tags.clone(),
nodes: nodes.clone(),
},
);
// Clip way nodes for standalone way processing (not relations)
let clipped_nodes = clip_way_to_bbox(&nodes, &xzbbox);
// Skip ways that are completely outside the bbox (empty after clipping)
if clipped_nodes.is_empty() {
continue;
}
let processed: ProcessedWay = ProcessedWay {
id: element.id,
tags: element.tags.clone().unwrap_or_default(),
nodes,
tags: tags.clone(),
nodes: clipped_nodes.clone(),
};
ways_map.insert(element.id, processed.clone());
processed_elements.push(ProcessedElement::Way(processed));
}
@@ -247,6 +275,9 @@ pub fn parse_osm_data(
continue;
};
// Water relations require unclipped ways for ring merging in water_areas.rs
let is_water_relation = is_water_element(tags);
let members: Vec<ProcessedMember> = element
.members
.iter()
@@ -271,7 +302,26 @@ pub fn parse_osm_data(
}
};
Some(ProcessedMember { role, way })
// Water relations: keep unclipped for ring merging
// Non-water relations: clip member ways now
let final_way = if is_water_relation {
way
} else {
let clipped_nodes = clip_way_to_bbox(&way.nodes, &xzbbox);
if clipped_nodes.is_empty() {
return None;
}
ProcessedWay {
id: way.id,
tags: way.tags,
nodes: clipped_nodes,
}
};
Some(ProcessedMember {
role,
way: final_way,
})
})
.collect();
@@ -289,6 +339,30 @@ pub fn parse_osm_data(
(processed_elements, xzbbox)
}
/// Returns true if tags indicate a water element handled by water_areas.rs.
fn is_water_element(tags: &HashMap<String, String>) -> bool {
// Check for explicit water tag
if tags.contains_key("water") {
return true;
}
// Check for natural=water or natural=bay
if let Some(natural_val) = tags.get("natural") {
if natural_val == "water" || natural_val == "bay" {
return true;
}
}
// Check for waterway=dock (also handled as water area)
if let Some(waterway_val) = tags.get("waterway") {
if waterway_val == "dock" {
return true;
}
}
false
}
const PRIORITY_ORDER: [&str; 6] = [
"entrance", "building", "highway", "waterway", "water", "barrier",
];

View File

@@ -56,3 +56,12 @@ pub fn emit_gui_error(message: &str) {
};
emit_gui_progress_update(0.0, &format!("Error! {truncated_message}"));
}
/// Emits an event when the world map preview is ready
pub fn emit_map_preview_ready() {
if let Some(window) = get_main_window() {
if let Err(e) = window.emit("map-preview-ready", ()) {
eprintln!("Failed to emit map-preview-ready event: {}", e);
}
}
}