Skip to content

Commit a5a2ece

Browse files
authored
Merge pull request #270 from rgis-app/fix/grid-label-z-value
Fix grid labels not visible
2 parents fe0bded + 5e62dfb commit a5a2ece

File tree

6 files changed

+221
-14
lines changed

6 files changed

+221
-14
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ bevy = { version = "0.18", default-features = false, features = [
4949
"bevy_sprite",
5050
"bevy_sprite_render",
5151
"bevy_ui",
52+
"bevy_ui_render",
5253
"bevy_picking",
5354
"bevy_text",
5455
"default_font",

rgis-grid/src/lib.rs

Lines changed: 196 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,28 @@ use bevy::prelude::*;
22

33
pub struct Plugin;
44

5+
#[derive(Resource)]
6+
struct GridFont(Handle<Font>);
7+
58
impl bevy::app::Plugin for Plugin {
69
fn build(&self, app: &mut App) {
7-
app.add_systems(Startup, spawn_grid);
8-
app.add_systems(PostUpdate, (update_grid, update_grid_labels).chain());
10+
app.add_systems(Startup, (spawn_grid, load_grid_font));
11+
app.add_systems(PostUpdate, update_grid);
12+
app.add_systems(Update, update_grid_labels);
913
}
1014
}
1115

16+
fn load_grid_font(mut commands: Commands, asset_server: Res<AssetServer>) {
17+
let font = asset_server.load("fonts/RobotoCondensed-VariableFont_wght.ttf");
18+
commands.insert_resource(GridFont(font));
19+
}
20+
1221
// ── Degree-friendly intervals ────────────────────────────────────────────────
1322

1423
/// Degree-friendly intervals sorted largest → smallest.
1524
/// Whole degrees, then arc-minutes, then arc-seconds.
1625
const DEGREE_INTERVALS: &[f32] = &[
26+
180.0,
1727
90.0,
1828
45.0,
1929
30.0,
@@ -141,11 +151,11 @@ fn format_degree(value: f64, is_latitude: bool) -> String {
141151
let sec = (rem - min as f64) * 60.0;
142152

143153
if deg == 0 && min == 0 && sec.abs() > 0.01 {
144-
format!("{sec:.0}\" {suffix}")
154+
format!("{sec:.0}\u{2033} {suffix}")
145155
} else if sec.abs() > 0.01 {
146-
format!("{deg}\u{00b0} {min}' {sec:.0}\" {suffix}")
156+
format!("{deg}\u{00b0}{min}\u{2032}{sec:.0}\u{2033} {suffix}")
147157
} else if min > 0 {
148-
format!("{deg}\u{00b0} {min}' {suffix}")
158+
format!("{deg}\u{00b0}{min}\u{2032} {suffix}")
149159
} else {
150160
format!("{deg}\u{00b0} {suffix}")
151161
}
@@ -166,7 +176,6 @@ fn format_value(value: f32) -> String {
166176

167177
const MIN_LINE_SPACING_PX: f32 = 80.0;
168178
const GRID_Z: f32 = -0.01;
169-
const LABEL_Z: f32 = -0.005;
170179
const LABEL_FONT_SIZE: f32 = 11.0;
171180
const LABEL_MARGIN_PX: f32 = 8.0;
172181

@@ -352,15 +361,21 @@ fn update_grid(
352361
let lat_top = y_to_lat(vp.world_top);
353362

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

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

360372
let first_lon = (lon_left / lon_interval as f64).floor() as i64;
361373
let last_lon = (lon_right / lon_interval as f64).ceil() as i64;
362374
for i in first_lon..=last_lon {
363375
let lon = i as f64 * lon_interval as f64;
376+
if lon.abs() > 180.0 {
377+
continue;
378+
}
364379
let x = lon_to_x(lon);
365380
add_rect(&mut positions, &mut indices, x, center_y, thickness, height);
366381
}
@@ -402,7 +417,9 @@ fn update_grid(
402417
}
403418
}
404419

405-
// ── Text2d label rendering ──────────────────────────────────────────────────
420+
// ── Grid label rendering (world-space Text2d) ──────────────────────────────
421+
422+
const LABEL_Z: f32 = -0.005;
406423

407424
/// Collected label data: world position + text + anchor.
408425
struct LabelSpec {
@@ -419,9 +436,36 @@ fn update_grid_labels(
419436
windows: Query<&Window, With<bevy::window::PrimaryWindow>>,
420437
clear_color: Res<ClearColor>,
421438
target_crs: Option<Res<rgis_crs::TargetCrs>>,
439+
grid_font: Option<Res<GridFont>>,
422440
side_panel_width: Res<rgis_units::SidePanelWidth>,
423441
bottom_panel_height: Res<rgis_units::BottomPanelHeight>,
442+
mut last_state: Local<LastCameraState>,
424443
) {
444+
// Wait for the font to be available before spawning labels.
445+
let Some(ref font_res) = grid_font else {
446+
return;
447+
};
448+
449+
let Ok(transform) = camera_query.single() else {
450+
return;
451+
};
452+
let Ok(window) = windows.single() else {
453+
return;
454+
};
455+
456+
let window_size = Vec2::new(window.width(), window.height());
457+
if transform.translation == last_state.translation
458+
&& transform.scale == last_state.scale
459+
&& window_size == last_state.window_size
460+
&& !label_query.is_empty()
461+
{
462+
return;
463+
}
464+
465+
last_state.translation = transform.translation;
466+
last_state.scale = transform.scale;
467+
last_state.window_size = window_size;
468+
425469
// Despawn all previous labels.
426470
for entity in label_query.iter() {
427471
commands.entity(entity).despawn();
@@ -486,15 +530,18 @@ fn update_grid_labels(
486530
let lat_top = y_to_lat(vp.world_top);
487531

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

491-
let lon_interval = nice_degree_interval(deg_per_px_lon, MIN_LINE_SPACING_PX);
492-
let lat_interval = nice_degree_interval(deg_per_px_lat, MIN_LINE_SPACING_PX);
534+
let interval = nice_degree_interval(deg_per_px_lon, MIN_LINE_SPACING_PX);
535+
let lon_interval = interval;
536+
let lat_interval = interval;
493537

494538
let first_lon = (lon_left / lon_interval as f64).floor() as i64;
495539
let last_lon = (lon_right / lon_interval as f64).ceil() as i64;
496540
for i in first_lon..=last_lon {
497541
let lon = i as f64 * lon_interval as f64;
542+
if lon.abs() > 180.0 {
543+
continue;
544+
}
498545
let x = lon_to_x(lon);
499546
labels.push(LabelSpec {
500547
world_x: x,
@@ -555,6 +602,7 @@ fn update_grid_labels(
555602
commands.spawn((
556603
Text2d::new(label.text),
557604
TextFont {
605+
font: font_res.0.clone(),
558606
font_size: LABEL_FONT_SIZE,
559607
..default()
560608
},
@@ -593,3 +641,138 @@ fn add_rect(
593641
indices.push(base + 2);
594642
indices.push(base + 3);
595643
}
644+
645+
#[cfg(test)]
646+
mod tests {
647+
use super::*;
648+
649+
// ── nice_degree_interval ────────────────────────────────────────────
650+
651+
#[test]
652+
fn degree_interval_zoomed_out_picks_large_interval() {
653+
// ~0.28 deg/px (full world across 1280px)
654+
let interval = nice_degree_interval(360.0 / 1280.0, 80.0);
655+
assert!(interval >= 15.0, "expected >=15°, got {interval}");
656+
}
657+
658+
#[test]
659+
fn degree_interval_zoomed_in_picks_small_interval() {
660+
// ~0.001 deg/px (city-level zoom)
661+
let interval = nice_degree_interval(0.001, 80.0);
662+
assert!(interval <= 1.0, "expected <=1°, got {interval}");
663+
}
664+
665+
#[test]
666+
fn degree_interval_returns_value_from_list() {
667+
let interval = nice_degree_interval(0.05, 80.0);
668+
assert!(
669+
DEGREE_INTERVALS.contains(&interval),
670+
"interval {interval} not in DEGREE_INTERVALS"
671+
);
672+
}
673+
674+
#[test]
675+
fn degree_interval_never_below_smallest() {
676+
let smallest = *DEGREE_INTERVALS.last().unwrap();
677+
let interval = nice_degree_interval(0.0000001, 80.0);
678+
assert!(interval >= smallest);
679+
}
680+
681+
// ── nice_interval (1-2-5 generic) ───────────────────────────────────
682+
683+
#[test]
684+
fn nice_interval_picks_round_values() {
685+
let interval = nice_interval(1.0, 80.0);
686+
// 80 units min spacing → should pick 100
687+
assert_eq!(interval, 100.0);
688+
}
689+
690+
#[test]
691+
fn nice_interval_scales_with_camera() {
692+
let a = nice_interval(1.0, 80.0);
693+
let b = nice_interval(10.0, 80.0);
694+
assert!(b > a, "larger camera scale should give larger interval");
695+
}
696+
697+
// ── Mercator round-trip ─────────────────────────────────────────────
698+
699+
#[test]
700+
fn lon_x_round_trip() {
701+
for lon in [-180.0, -90.0, 0.0, 45.0, 180.0] {
702+
let x = lon_to_x(lon);
703+
let back = x_to_lon(x);
704+
assert!((back - lon).abs() < 1e-4, "lon {lon} -> x {x} -> {back}");
705+
}
706+
}
707+
708+
#[test]
709+
fn lat_y_round_trip() {
710+
for lat in [-85.0, -45.0, 0.0, 45.0, 85.0] {
711+
let y = lat_to_y(lat);
712+
let back = y_to_lat(y);
713+
assert!((back - lat).abs() < 1e-6, "lat {lat} -> y {y} -> {back}");
714+
}
715+
}
716+
717+
#[test]
718+
fn equator_maps_to_near_zero() {
719+
assert!(lat_to_y(0.0).abs() < 1e-6);
720+
assert!(lon_to_x(0.0).abs() < 1e-6);
721+
}
722+
723+
#[test]
724+
fn mercator_y_increases_with_latitude() {
725+
assert!(lat_to_y(45.0) > lat_to_y(0.0));
726+
assert!(lat_to_y(0.0) > lat_to_y(-45.0));
727+
}
728+
729+
// ── format_degree ───────────────────────────────────────────────────
730+
731+
#[test]
732+
fn format_degree_zero_latitude() {
733+
let s = format_degree(0.0, true);
734+
assert_eq!(s, "0\u{00b0} N");
735+
}
736+
737+
#[test]
738+
fn format_degree_zero_longitude() {
739+
let s = format_degree(0.0, false);
740+
assert_eq!(s, "0\u{00b0} E");
741+
}
742+
743+
#[test]
744+
fn format_degree_negative_latitude() {
745+
let s = format_degree(-45.0, true);
746+
assert!(s.ends_with("S"), "expected S suffix, got {s}");
747+
assert!(s.contains("45"), "expected 45 in {s}");
748+
}
749+
750+
#[test]
751+
fn format_degree_with_minutes() {
752+
let s = format_degree(45.5, true);
753+
assert!(s.contains("30\u{2032}"), "expected 30′ in {s}");
754+
}
755+
756+
#[test]
757+
fn format_degree_west_longitude() {
758+
let s = format_degree(-90.0, false);
759+
assert!(s.ends_with("W"), "expected W suffix, got {s}");
760+
}
761+
762+
// ── format_value ────────────────────────────────────────────────────
763+
764+
#[test]
765+
fn format_value_large() {
766+
assert_eq!(format_value(1_500_000.0), "1500000");
767+
}
768+
769+
#[test]
770+
fn format_value_medium() {
771+
assert_eq!(format_value(123.4), "123.4");
772+
}
773+
774+
#[test]
775+
fn format_value_small() {
776+
assert_eq!(format_value(0.0012), "0.0012");
777+
}
778+
}

rgis-ui/src/systems.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,28 @@ fn apply_deferred_settings(
691691
settings_to_apply.toggle_dark_mode = false;
692692
}
693693

694+
fn setup_egui_fonts(mut bevy_egui_ctx: EguiContexts) -> Result {
695+
let bevy_egui_ctx_mut = bevy_egui_ctx.ctx_mut()?;
696+
let roboto_data = include_bytes!("../../rgis/assets/fonts/Roboto-VariableFont_wdth_wght.ttf");
697+
let mut fonts = egui::FontDefinitions::default();
698+
fonts.font_data.insert(
699+
"Roboto".to_owned(),
700+
egui::FontData::from_static(roboto_data).into(),
701+
);
702+
fonts
703+
.families
704+
.entry(egui::FontFamily::Proportional)
705+
.or_default()
706+
.insert(0, "Roboto".to_owned());
707+
fonts
708+
.families
709+
.entry(egui::FontFamily::Monospace)
710+
.or_default()
711+
.insert(0, "Roboto".to_owned());
712+
bevy_egui_ctx_mut.set_fonts(fonts);
713+
Ok(())
714+
}
715+
694716
/// Synchronizes the egui theme and clear color when `RgisSettings` changes.
695717
/// Thanks to the deferred-mutation pattern in `render_top` / `apply_deferred_settings`,
696718
/// `RgisSettings` is only marked as changed when a setting is actually toggled,
@@ -875,7 +897,7 @@ pub fn configure(app: &mut App) {
875897

876898
app.add_systems(
877899
PostStartup,
878-
(bevy_egui::setup_primary_egui_context_system, sync_egui_theme).chain(),
900+
(bevy_egui::setup_primary_egui_context_system, setup_egui_fonts, sync_egui_theme).chain(),
879901
);
880902

881903
app.configure_sets(
477 KB
Binary file not shown.
363 KB
Binary file not shown.

0 commit comments

Comments
 (0)