Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
b64bee6
Try high z-value for grid labels to debug WASM visibility
frewsxcv Mar 10, 2026
8b89cc7
Use Bevy UI Text nodes for grid labels instead of Text2d
frewsxcv Mar 10, 2026
f84f827
Debug: add red background and max ZIndex to grid labels
frewsxcv Mar 10, 2026
94306b6
Switch from bevy_egui fork to official bevy_egui 0.39
frewsxcv Mar 10, 2026
d25573e
Debug: add red background and max ZIndex to grid labels
frewsxcv Mar 10, 2026
7b1e3fd
Debug: add tracing to grid label spawning
frewsxcv Mar 10, 2026
a8eadc2
Add bevy_ui_render feature to enable UI node rendering
frewsxcv Mar 10, 2026
73a0030
Debug: add static HELLO WORLD test UI to verify Bevy UI rendering
frewsxcv Mar 10, 2026
bf69e00
Fix grid labels not rendering: add change detection guard
frewsxcv Mar 10, 2026
d30f76a
Switch back to bevy_egui fork to test label compatibility
frewsxcv Mar 10, 2026
2140378
Remove redundant bevy_ui_render feature (already pulled in by bevy_eg…
frewsxcv Mar 10, 2026
fbe9238
Use ASCII-only degree formatting (default font lacks Unicode symbols)
frewsxcv Mar 10, 2026
6c7f671
Load RobotoMono font for grid labels to support degree symbols
frewsxcv Mar 10, 2026
ef2b50b
Fix: wait for font resource before spawning labels, re-spawn if empty
frewsxcv Mar 10, 2026
c3aee05
Embed RobotoMono font at compile time instead of loading from assets
frewsxcv Mar 10, 2026
124ec0e
Debug: test labels with default vs RobotoMono font
frewsxcv Mar 10, 2026
3910606
Re-add bevy_ui_render feature (required even with fork)
frewsxcv Mar 10, 2026
212e303
Remove debug test labels, keep RobotoMono for grid labels
frewsxcv Mar 10, 2026
5265e11
Use AssetServer to load grid font instead of include_bytes
frewsxcv Mar 10, 2026
925bcdc
Use Roboto Condensed for grid labels (narrower, better for map coordi…
frewsxcv Mar 10, 2026
0f6af97
Use Roboto font for all egui UI elements
frewsxcv Mar 10, 2026
faf9944
Switch grid labels from Bevy UI to Text2d (world-space)
frewsxcv Mar 10, 2026
dfa3415
Fix sparse horizontal grid lines in WebMercator
frewsxcv Mar 10, 2026
d4d6812
Use same degree interval for lat and lon in WebMercator grid
frewsxcv Mar 10, 2026
2058c41
Add 180° to degree intervals so vertical lines thin out at max zoom
frewsxcv Mar 10, 2026
bbd6e73
Clamp WebMercator grid lines to ±180° longitude
frewsxcv Mar 10, 2026
07bddd3
Add unit tests for grid utility functions
frewsxcv Mar 10, 2026
e9f6eda
Update Playwright snapshots for grid label and font changes
frewsxcv Mar 10, 2026
5e62dfb
Revert broken raster snapshots that lost imagery
frewsxcv Mar 10, 2026
9f5803f
Use include_bytes for grid font instead of AssetServer
frewsxcv Mar 10, 2026
248f031
Merge remote-tracking branch 'origin/main' into fix/grid-label-z-value
frewsxcv Mar 10, 2026
aed43f2
Update Playwright snapshots after merging main
frewsxcv Mar 10, 2026
4466b2a
Merge remote-tracking branch 'origin/main' into merge-main-270
frewsxcv Mar 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ bevy = { version = "0.18", default-features = false, features = [
"bevy_sprite",
"bevy_sprite_render",
"bevy_ui",
"bevy_ui_render",
"bevy_picking",
"bevy_text",
"default_font",
Expand Down
210 changes: 197 additions & 13 deletions rgis-grid/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,29 @@ use bevy::prelude::*;

pub struct Plugin;

#[derive(Resource)]
struct GridFont(Handle<Font>);

impl bevy::app::Plugin for Plugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, spawn_grid);
app.add_systems(PostUpdate, (update_grid, update_grid_labels).chain());
app.add_systems(Startup, (spawn_grid, load_grid_font));
app.add_systems(PostUpdate, update_grid);
app.add_systems(Update, update_grid_labels);
}
}

fn load_grid_font(mut commands: Commands, mut fonts: ResMut<Assets<Font>>) {
let font_data = include_bytes!("../../rgis/assets/fonts/RobotoCondensed-VariableFont_wght.ttf");
let font = fonts.add(Font::try_from_bytes(font_data.to_vec()).expect("Failed to load grid font"));
commands.insert_resource(GridFont(font));
}

// ── Degree-friendly intervals ────────────────────────────────────────────────

/// Degree-friendly intervals sorted largest → smallest.
/// Whole degrees, then arc-minutes, then arc-seconds.
const DEGREE_INTERVALS: &[f32] = &[
180.0,
90.0,
45.0,
30.0,
Expand Down Expand Up @@ -141,11 +152,11 @@ fn format_degree(value: f64, is_latitude: bool) -> String {
let sec = (rem - min as f64) * 60.0;

if deg == 0 && min == 0 && sec.abs() > 0.01 {
format!("{sec:.0}\" {suffix}")
format!("{sec:.0}\u{2033} {suffix}")
} else if sec.abs() > 0.01 {
format!("{deg}\u{00b0} {min}' {sec:.0}\" {suffix}")
format!("{deg}\u{00b0}{min}\u{2032}{sec:.0}\u{2033} {suffix}")
} else if min > 0 {
format!("{deg}\u{00b0} {min}' {suffix}")
format!("{deg}\u{00b0}{min}\u{2032} {suffix}")
} else {
format!("{deg}\u{00b0} {suffix}")
}
Expand All @@ -166,7 +177,6 @@ fn format_value(value: f32) -> String {

const MIN_LINE_SPACING_PX: f32 = 80.0;
const GRID_Z: f32 = -0.01;
const LABEL_Z: f32 = -0.005;
const LABEL_FONT_SIZE: f32 = 11.0;
const LABEL_MARGIN_PX: f32 = 8.0;

Expand Down Expand Up @@ -352,15 +362,21 @@ fn update_grid(
let lat_top = y_to_lat(vp.world_top);

let deg_per_px_lon = (lon_right - lon_left) as f32 / vp.win_w;
let deg_per_px_lat = (lat_top - lat_bottom) as f32 / vp.win_h;

let lon_interval = nice_degree_interval(deg_per_px_lon, MIN_LINE_SPACING_PX);
let lat_interval = nice_degree_interval(deg_per_px_lat, MIN_LINE_SPACING_PX);
// Use the same degree interval for both axes. Mercator's non-linear
// y-axis makes per-pixel latitude density meaningless, and geographic
// grids conventionally use uniform degree spacing.
let interval = nice_degree_interval(deg_per_px_lon, MIN_LINE_SPACING_PX);
let lon_interval = interval;
let lat_interval = interval;

let first_lon = (lon_left / lon_interval as f64).floor() as i64;
let last_lon = (lon_right / lon_interval as f64).ceil() as i64;
for i in first_lon..=last_lon {
let lon = i as f64 * lon_interval as f64;
if lon.abs() > 180.0 {
continue;
}
let x = lon_to_x(lon);
add_rect(&mut positions, &mut indices, x, center_y, thickness, height);
}
Expand Down Expand Up @@ -402,7 +418,9 @@ fn update_grid(
}
}

// ── Text2d label rendering ──────────────────────────────────────────────────
// ── Grid label rendering (world-space Text2d) ──────────────────────────────

const LABEL_Z: f32 = -0.005;

/// Collected label data: world position + text + anchor.
struct LabelSpec {
Expand All @@ -419,9 +437,36 @@ fn update_grid_labels(
windows: Query<&Window, With<bevy::window::PrimaryWindow>>,
clear_color: Res<ClearColor>,
target_crs: Option<Res<rgis_crs::TargetCrs>>,
grid_font: Option<Res<GridFont>>,
side_panel_width: Res<rgis_units::SidePanelWidth>,
bottom_panel_height: Res<rgis_units::BottomPanelHeight>,
mut last_state: Local<LastCameraState>,
) {
// Wait for the font to be available before spawning labels.
let Some(ref font_res) = grid_font else {
return;
};

let Ok(transform) = camera_query.single() else {
return;
};
let Ok(window) = windows.single() else {
return;
};

let window_size = Vec2::new(window.width(), window.height());
if transform.translation == last_state.translation
&& transform.scale == last_state.scale
&& window_size == last_state.window_size
&& !label_query.is_empty()
{
return;
}

last_state.translation = transform.translation;
last_state.scale = transform.scale;
last_state.window_size = window_size;

// Despawn all previous labels.
for entity in label_query.iter() {
commands.entity(entity).despawn();
Expand Down Expand Up @@ -486,15 +531,18 @@ fn update_grid_labels(
let lat_top = y_to_lat(vp.world_top);

let deg_per_px_lon = (lon_right - lon_left) as f32 / vp.win_w;
let deg_per_px_lat = (lat_top - lat_bottom) as f32 / vp.win_h;

let lon_interval = nice_degree_interval(deg_per_px_lon, MIN_LINE_SPACING_PX);
let lat_interval = nice_degree_interval(deg_per_px_lat, MIN_LINE_SPACING_PX);
let interval = nice_degree_interval(deg_per_px_lon, MIN_LINE_SPACING_PX);
let lon_interval = interval;
let lat_interval = interval;

let first_lon = (lon_left / lon_interval as f64).floor() as i64;
let last_lon = (lon_right / lon_interval as f64).ceil() as i64;
for i in first_lon..=last_lon {
let lon = i as f64 * lon_interval as f64;
if lon.abs() > 180.0 {
continue;
}
let x = lon_to_x(lon);
labels.push(LabelSpec {
world_x: x,
Expand Down Expand Up @@ -555,6 +603,7 @@ fn update_grid_labels(
commands.spawn((
Text2d::new(label.text),
TextFont {
font: font_res.0.clone(),
font_size: LABEL_FONT_SIZE,
..default()
},
Expand Down Expand Up @@ -593,3 +642,138 @@ fn add_rect(
indices.push(base + 2);
indices.push(base + 3);
}

#[cfg(test)]
mod tests {
use super::*;

// ── nice_degree_interval ────────────────────────────────────────────

#[test]
fn degree_interval_zoomed_out_picks_large_interval() {
// ~0.28 deg/px (full world across 1280px)
let interval = nice_degree_interval(360.0 / 1280.0, 80.0);
assert!(interval >= 15.0, "expected >=15°, got {interval}");
}

#[test]
fn degree_interval_zoomed_in_picks_small_interval() {
// ~0.001 deg/px (city-level zoom)
let interval = nice_degree_interval(0.001, 80.0);
assert!(interval <= 1.0, "expected <=1°, got {interval}");
}

#[test]
fn degree_interval_returns_value_from_list() {
let interval = nice_degree_interval(0.05, 80.0);
assert!(
DEGREE_INTERVALS.contains(&interval),
"interval {interval} not in DEGREE_INTERVALS"
);
}

#[test]
fn degree_interval_never_below_smallest() {
let smallest = *DEGREE_INTERVALS.last().unwrap();
let interval = nice_degree_interval(0.0000001, 80.0);
assert!(interval >= smallest);
}

// ── nice_interval (1-2-5 generic) ───────────────────────────────────

#[test]
fn nice_interval_picks_round_values() {
let interval = nice_interval(1.0, 80.0);
// 80 units min spacing → should pick 100
assert_eq!(interval, 100.0);
}

#[test]
fn nice_interval_scales_with_camera() {
let a = nice_interval(1.0, 80.0);
let b = nice_interval(10.0, 80.0);
assert!(b > a, "larger camera scale should give larger interval");
}

// ── Mercator round-trip ─────────────────────────────────────────────

#[test]
fn lon_x_round_trip() {
for lon in [-180.0, -90.0, 0.0, 45.0, 180.0] {
let x = lon_to_x(lon);
let back = x_to_lon(x);
assert!((back - lon).abs() < 1e-4, "lon {lon} -> x {x} -> {back}");
}
}

#[test]
fn lat_y_round_trip() {
for lat in [-85.0, -45.0, 0.0, 45.0, 85.0] {
let y = lat_to_y(lat);
let back = y_to_lat(y);
assert!((back - lat).abs() < 1e-6, "lat {lat} -> y {y} -> {back}");
}
}

#[test]
fn equator_maps_to_near_zero() {
assert!(lat_to_y(0.0).abs() < 1e-6);
assert!(lon_to_x(0.0).abs() < 1e-6);
}

#[test]
fn mercator_y_increases_with_latitude() {
assert!(lat_to_y(45.0) > lat_to_y(0.0));
assert!(lat_to_y(0.0) > lat_to_y(-45.0));
}

// ── format_degree ───────────────────────────────────────────────────

#[test]
fn format_degree_zero_latitude() {
let s = format_degree(0.0, true);
assert_eq!(s, "0\u{00b0} N");
}

#[test]
fn format_degree_zero_longitude() {
let s = format_degree(0.0, false);
assert_eq!(s, "0\u{00b0} E");
}

#[test]
fn format_degree_negative_latitude() {
let s = format_degree(-45.0, true);
assert!(s.ends_with("S"), "expected S suffix, got {s}");
assert!(s.contains("45"), "expected 45 in {s}");
}

#[test]
fn format_degree_with_minutes() {
let s = format_degree(45.5, true);
assert!(s.contains("30\u{2032}"), "expected 30′ in {s}");
}

#[test]
fn format_degree_west_longitude() {
let s = format_degree(-90.0, false);
assert!(s.ends_with("W"), "expected W suffix, got {s}");
}

// ── format_value ────────────────────────────────────────────────────

#[test]
fn format_value_large() {
assert_eq!(format_value(1_500_000.0), "1500000");
}

#[test]
fn format_value_medium() {
assert_eq!(format_value(123.4), "123.4");
}

#[test]
fn format_value_small() {
assert_eq!(format_value(0.0012), "0.0012");
}
}
24 changes: 23 additions & 1 deletion rgis-ui/src/systems.rs
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,28 @@ fn apply_deferred_settings(
settings_to_apply.toggle_dark_mode = false;
}

fn setup_egui_fonts(mut bevy_egui_ctx: EguiContexts) -> Result {
let bevy_egui_ctx_mut = bevy_egui_ctx.ctx_mut()?;
let roboto_data = include_bytes!("../../rgis/assets/fonts/Roboto-VariableFont_wdth_wght.ttf");
let mut fonts = egui::FontDefinitions::default();
fonts.font_data.insert(
"Roboto".to_owned(),
egui::FontData::from_static(roboto_data).into(),
);
fonts
.families
.entry(egui::FontFamily::Proportional)
.or_default()
.insert(0, "Roboto".to_owned());
fonts
.families
.entry(egui::FontFamily::Monospace)
.or_default()
.insert(0, "Roboto".to_owned());
bevy_egui_ctx_mut.set_fonts(fonts);
Ok(())
}

/// Synchronizes the egui theme and clear color when `RgisSettings` changes.
/// Thanks to the deferred-mutation pattern in `render_top` / `apply_deferred_settings`,
/// `RgisSettings` is only marked as changed when a setting is actually toggled,
Expand Down Expand Up @@ -875,7 +897,7 @@ pub fn configure(app: &mut App) {

app.add_systems(
PostStartup,
(bevy_egui::setup_primary_egui_context_system, sync_egui_theme).chain(),
(bevy_egui::setup_primary_egui_context_system, setup_egui_fonts, sync_egui_theme).chain(),
);

app.configure_sets(
Expand Down
Binary file not shown.
Binary file not shown.
Loading