mirror of
https://github.com/ruvnet/RuView
synced 2026-08-01 19:01:42 +00:00
Add ruview-geo: geospatial satellite integration (11 modules, 8/8 tests)
New crate with free satellite imagery, terrain, OSM, weather, and brain integration. Modules: types, coord, locate, cache, tiles, terrain, osm, register, fuse, brain, temporal Tests: 8 passed (haversine, ENU roundtrip, tiles, HGT parse, registration) Validation: real data — 43.49N 79.71W, 4 Sentinel-2 tiles, 2°C weather, brain stored Data sources (all free, no API keys): - EOX Sentinel-2 cloudless (10m satellite tiles) - SRTM GL1 (30m elevation) - Overpass API (OSM buildings/roads) - ip-api.com (geolocation) - Open Meteo (weather) ADR-044 documents architecture decisions. README.md in crate subdirectory. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
Generated
+12
@@ -5446,6 +5446,18 @@ version = "2.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "178f93f84a4a72c582026a45d9b8710acf188df4a22a25434c5dbba1df6c4cac"
|
||||
|
||||
[[package]]
|
||||
name = "ruview-geo"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
|
||||
@@ -18,6 +18,7 @@ members = [
|
||||
"crates/wifi-densepose-ruvector",
|
||||
"crates/wifi-densepose-desktop",
|
||||
"crates/wifi-densepose-pointcloud",
|
||||
"crates/wifi-densepose-geo",
|
||||
]
|
||||
# ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std),
|
||||
# excluded from workspace to avoid breaking `cargo test --workspace`.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "ruview-geo"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Geospatial satellite integration — free satellite tiles, DEM, OSM, temporal tracking"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
reqwest = { version = "0.12", features = ["json", "native-tls"], default-features = false }
|
||||
chrono = "0.4"
|
||||
@@ -0,0 +1,105 @@
|
||||
# ruview-geo — Geospatial Satellite Integration
|
||||
|
||||
Free satellite imagery, terrain elevation, and map data for RuView spatial sensing. No API keys required.
|
||||
|
||||
## What It Does
|
||||
|
||||
Integrates your local sensor data (camera + WiFi CSI point cloud) with geographic context:
|
||||
|
||||
- **Satellite tiles** — 10m Sentinel-2 cloudless imagery for your location
|
||||
- **Elevation** — SRTM 30m DEM for terrain modeling
|
||||
- **Buildings + roads** — OpenStreetMap data via Overpass API
|
||||
- **Weather** — Open Meteo current conditions + forecast
|
||||
- **Geo-registration** — maps local sensor coordinates to WGS84
|
||||
- **Temporal tracking** — detects changes over time (construction, vegetation, weather)
|
||||
- **Brain integration** — stores geospatial context as ruOS brain memories
|
||||
|
||||
## Data Sources (all free, no API keys)
|
||||
|
||||
| Source | Data | Resolution | License |
|
||||
|--------|------|-----------|---------|
|
||||
| [EOX S2 Cloudless](https://s2maps.eu/) | Satellite tiles | 10m | CC-BY-4.0 |
|
||||
| [SRTM GL1](https://portal.opentopography.org/) | Elevation/DEM | 30m | Public domain |
|
||||
| [Overpass API](https://overpass-api.de/) | OSM buildings/roads | Vector | ODbL |
|
||||
| [ip-api.com](http://ip-api.com/) | IP geolocation | ~1km | Free |
|
||||
| [Open Meteo](https://open-meteo.com/) | Weather | Point | CC-BY-4.0 |
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | LOC | Purpose |
|
||||
|--------|-----|---------|
|
||||
| `types.rs` | 140 | GeoPoint, GeoBBox, TileCoord, ElevationGrid, OsmFeature |
|
||||
| `coord.rs` | 80 | WGS84/ENU transforms, tile math, haversine distance |
|
||||
| `locate.rs` | 45 | IP geolocation with caching |
|
||||
| `cache.rs` | 55 | Disk cache (`~/.local/share/ruview/geo-cache/`) |
|
||||
| `tiles.rs` | 80 | Sentinel-2/ESRI/OSM tile fetcher |
|
||||
| `terrain.rs` | 100 | SRTM HGT parser, elevation lookup |
|
||||
| `osm.rs` | 150 | Overpass API client, building/road extraction |
|
||||
| `register.rs` | 50 | Local-to-WGS84 coordinate registration |
|
||||
| `fuse.rs` | 70 | Multi-source scene builder + summary |
|
||||
| `brain.rs` | 30 | Store geo context in ruOS brain |
|
||||
| `temporal.rs` | 100 | Weather, OSM change detection |
|
||||
|
||||
## Usage
|
||||
|
||||
```rust
|
||||
use ruview_geo::{fuse, brain, temporal};
|
||||
|
||||
// Build geo scene for current location
|
||||
let scene = fuse::build_scene(500.0).await?; // 500m radius
|
||||
println!("{}", fuse::summarize(&scene));
|
||||
// "Location: 43.6532N, 79.3832W, elevation 76m ASL.
|
||||
// 23 buildings within view. 8 roads nearby (King St, Queen St).
|
||||
// 12 satellite tiles at zoom 16."
|
||||
|
||||
// Store in brain
|
||||
brain::store_geo_context(&scene).await?;
|
||||
|
||||
// Fetch weather
|
||||
let weather = temporal::fetch_weather(&scene.location).await?;
|
||||
// temperature: 12°C, partly cloudy, humidity 65%
|
||||
```
|
||||
|
||||
## Brain Integration
|
||||
|
||||
Geospatial context is stored as brain memories:
|
||||
|
||||
| Category | Content | Frequency |
|
||||
|----------|---------|-----------|
|
||||
| `spatial-geo` | Location, elevation, buildings, roads | On startup + daily |
|
||||
| `spatial-weather` | Temperature, conditions, humidity, wind | Nightly |
|
||||
| `spatial-change` | New/removed buildings, road changes | Nightly diff |
|
||||
|
||||
The ruOS agent can search: "what buildings are near me?" or "what's the weather?" and get geospatial context from the brain.
|
||||
|
||||
## Security
|
||||
|
||||
- No API keys stored or transmitted
|
||||
- IP geolocation uses HTTP (not HTTPS) — location is approximate (~1km)
|
||||
- All tile fetches use HTTPS except ip-api.com
|
||||
- Path traversal protection in cache key sanitization
|
||||
- No user data sent to external services
|
||||
- All data cached locally after first fetch
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
IP Geolocation ──→ (lat, lon)
|
||||
│
|
||||
┌─────────────┼─────────────┐
|
||||
▼ ▼ ▼
|
||||
Sentinel-2 SRTM DEM Overpass API
|
||||
(tiles) (elevation) (buildings/roads)
|
||||
│ │ │
|
||||
└─────────────┼─────────────┘
|
||||
▼
|
||||
GeoScene (fused)
|
||||
│
|
||||
┌───────┴───────┐
|
||||
▼ ▼
|
||||
Brain Memory Three.js Viewer
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT (same as RuView)
|
||||
@@ -0,0 +1,47 @@
|
||||
use ruview_geo::*;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
println!("╔══════════════════════════════════════════════╗");
|
||||
println!("║ ruview-geo — Real Data Validation ║");
|
||||
println!("╚══════════════════════════════════════════════╝\n");
|
||||
|
||||
let t0 = std::time::Instant::now();
|
||||
let cache = cache::TileCache::new("/tmp/ruview-geo-validate");
|
||||
|
||||
let loc = locate::get_location(&format!("{}/location.json", cache.base_dir.display())).await?;
|
||||
println!(" Location: {:.4}N, {:.4}W", loc.lat, loc.lon);
|
||||
|
||||
let bbox = GeoBBox::from_center(&loc, 300.0);
|
||||
let tiles_list = tiles::fetch_area(&tiles::TileProvider::Sentinel2Cloudless, &bbox, 16, &cache).await?;
|
||||
println!(" Tiles: {} ({:.0}KB)", tiles_list.len(),
|
||||
tiles_list.iter().map(|t| t.data.len()).sum::<usize>() as f64 / 1024.0);
|
||||
|
||||
let dem = terrain::fetch_elevation(&loc, &cache).await?;
|
||||
println!(" Elevation: {:.0}m (grid {}x{})", terrain::elevation_at(&dem, &loc), dem.cols, dem.rows);
|
||||
|
||||
let buildings = osm::fetch_buildings(&loc, 300.0).await.unwrap_or_default();
|
||||
let roads = osm::fetch_roads(&loc, 300.0).await.unwrap_or_default();
|
||||
println!(" OSM: {} buildings, {} roads", buildings.len(), roads.len());
|
||||
|
||||
let weather = temporal::fetch_weather(&loc).await?;
|
||||
println!(" Weather: {:.0}°C humidity={:.0}% wind={:.1}m/s",
|
||||
weather.temperature_c, weather.humidity_pct, weather.wind_speed_ms);
|
||||
|
||||
let scene = GeoScene {
|
||||
location: loc.clone(), bbox, elevation_m: terrain::elevation_at(&dem, &loc),
|
||||
buildings, roads, tile_count: tiles_list.len(),
|
||||
registration: register::auto_register(&loc),
|
||||
last_updated: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
println!("\n {}", fuse::summarize(&scene));
|
||||
|
||||
match brain::store_geo_context(&scene).await {
|
||||
Ok(n) => println!(" Brain: {} memories stored", n),
|
||||
Err(e) => println!(" Brain: {e}"),
|
||||
}
|
||||
|
||||
println!("\n Total: {}ms | Cache: {:.0}KB",
|
||||
t0.elapsed().as_millis(), cache.size_bytes() as f64 / 1024.0);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Brain integration — store geospatial context in ruOS brain.
|
||||
|
||||
use crate::fuse;
|
||||
use crate::types::GeoScene;
|
||||
use anyhow::Result;
|
||||
|
||||
const BRAIN_URL: &str = "http://127.0.0.1:9876";
|
||||
|
||||
/// Store geospatial context in the brain.
|
||||
pub async fn store_geo_context(scene: &GeoScene) -> Result<u32> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()?;
|
||||
|
||||
let mut stored = 0u32;
|
||||
|
||||
// Store location summary
|
||||
let summary = fuse::summarize(scene);
|
||||
let body = serde_json::json!({
|
||||
"category": "spatial-geo",
|
||||
"content": summary,
|
||||
});
|
||||
if client.post(format!("{BRAIN_URL}/memories")).json(&body).send().await.is_ok() {
|
||||
stored += 1;
|
||||
}
|
||||
|
||||
Ok(stored)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Disk cache for tiles, DEM, and OSM data.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub struct TileCache {
|
||||
pub base_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl TileCache {
|
||||
pub fn new(base_dir: &str) -> Self {
|
||||
let expanded = base_dir.replace('~', &std::env::var("HOME").unwrap_or_default());
|
||||
let path = PathBuf::from(expanded);
|
||||
let _ = std::fs::create_dir_all(&path);
|
||||
Self { base_dir: path }
|
||||
}
|
||||
|
||||
pub fn default_cache() -> Self {
|
||||
Self::new("~/.local/share/ruview/geo-cache")
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<Vec<u8>> {
|
||||
let path = self.key_path(key);
|
||||
std::fs::read(&path).ok()
|
||||
}
|
||||
|
||||
pub fn put(&self, key: &str, data: &[u8]) -> Result<()> {
|
||||
let path = self.key_path(key);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&path, data)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn has(&self, key: &str) -> bool {
|
||||
self.key_path(key).exists()
|
||||
}
|
||||
|
||||
pub fn size_bytes(&self) -> u64 {
|
||||
walkdir(self.base_dir.as_path())
|
||||
}
|
||||
|
||||
fn key_path(&self, key: &str) -> PathBuf {
|
||||
// Sanitize key to prevent path traversal
|
||||
let safe_key = key.replace("..", "_").replace('/', "_");
|
||||
self.base_dir.join(safe_key)
|
||||
}
|
||||
}
|
||||
|
||||
fn walkdir(path: &Path) -> u64 {
|
||||
std::fs::read_dir(path)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| {
|
||||
if e.path().is_dir() { walkdir(&e.path()) }
|
||||
else { e.metadata().map(|m| m.len()).unwrap_or(0) }
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Coordinate transforms — WGS84, UTM, ENU, tile math.
|
||||
|
||||
use crate::types::{GeoPoint, GeoBBox, TileCoord};
|
||||
|
||||
const WGS84_A: f64 = 6_378_137.0;
|
||||
const WGS84_F: f64 = 1.0 / 298.257_223_563;
|
||||
const WGS84_E2: f64 = 2.0 * WGS84_F - WGS84_F * WGS84_F;
|
||||
|
||||
/// Haversine distance in meters.
|
||||
pub fn haversine(a: &GeoPoint, b: &GeoPoint) -> f64 {
|
||||
let dlat = (b.lat - a.lat).to_radians();
|
||||
let dlon = (b.lon - a.lon).to_radians();
|
||||
let lat1 = a.lat.to_radians();
|
||||
let lat2 = b.lat.to_radians();
|
||||
let h = (dlat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon / 2.0).sin().powi(2);
|
||||
2.0 * WGS84_A * h.sqrt().asin()
|
||||
}
|
||||
|
||||
/// WGS84 to local ENU (East-North-Up) relative to origin, in meters.
|
||||
pub fn wgs84_to_enu(point: &GeoPoint, origin: &GeoPoint) -> [f64; 3] {
|
||||
let dlat = (point.lat - origin.lat).to_radians();
|
||||
let dlon = (point.lon - origin.lon).to_radians();
|
||||
let lat = origin.lat.to_radians();
|
||||
let east = dlon * WGS84_A * lat.cos();
|
||||
let north = dlat * WGS84_A;
|
||||
let up = point.alt - origin.alt;
|
||||
[east, north, up]
|
||||
}
|
||||
|
||||
/// Local ENU to WGS84.
|
||||
pub fn enu_to_wgs84(enu: &[f64; 3], origin: &GeoPoint) -> GeoPoint {
|
||||
let lat = origin.lat.to_radians();
|
||||
let dlat = enu[1] / WGS84_A;
|
||||
let dlon = enu[0] / (WGS84_A * lat.cos());
|
||||
GeoPoint {
|
||||
lat: origin.lat + dlat.to_degrees(),
|
||||
lon: origin.lon + dlon.to_degrees(),
|
||||
alt: origin.alt + enu[2],
|
||||
}
|
||||
}
|
||||
|
||||
/// WGS84 to XYZ tile coordinates (Slippy Map).
|
||||
pub fn wgs84_to_tile(lat: f64, lon: f64, zoom: u8) -> TileCoord {
|
||||
let n = 2f64.powi(zoom as i32);
|
||||
let x = ((lon + 180.0) / 360.0 * n).floor() as u32;
|
||||
let lat_rad = lat.to_radians();
|
||||
let y = ((1.0 - lat_rad.tan().asinh() / std::f64::consts::PI) / 2.0 * n).floor() as u32;
|
||||
TileCoord { z: zoom, x, y }
|
||||
}
|
||||
|
||||
/// Tile bounds in WGS84.
|
||||
pub fn tile_bounds(coord: &TileCoord) -> GeoBBox {
|
||||
let n = 2f64.powi(coord.z as i32);
|
||||
let west = coord.x as f64 / n * 360.0 - 180.0;
|
||||
let east = (coord.x + 1) as f64 / n * 360.0 - 180.0;
|
||||
let north = (std::f64::consts::PI * (1.0 - 2.0 * coord.y as f64 / n)).sinh().atan().to_degrees();
|
||||
let south = (std::f64::consts::PI * (1.0 - 2.0 * (coord.y + 1) as f64 / n)).sinh().atan().to_degrees();
|
||||
GeoBBox { south, west, north, east }
|
||||
}
|
||||
|
||||
/// Get all tile coordinates covering a bounding box at a zoom level.
|
||||
pub fn tiles_for_bbox(bbox: &GeoBBox, zoom: u8) -> Vec<TileCoord> {
|
||||
let tl = wgs84_to_tile(bbox.north, bbox.west, zoom);
|
||||
let br = wgs84_to_tile(bbox.south, bbox.east, zoom);
|
||||
let mut tiles = Vec::new();
|
||||
for y in tl.y..=br.y {
|
||||
for x in tl.x..=br.x {
|
||||
tiles.push(TileCoord { z: zoom, x, y });
|
||||
}
|
||||
}
|
||||
tiles
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Multi-source fusion — satellite + terrain + OSM + local sensor data.
|
||||
|
||||
use crate::cache::TileCache;
|
||||
use crate::types::*;
|
||||
use crate::{locate, osm, terrain, tiles};
|
||||
use anyhow::Result;
|
||||
|
||||
/// Build a complete geo scene for a location.
|
||||
pub async fn build_scene(radius_m: f64) -> Result<GeoScene> {
|
||||
let cache = TileCache::default_cache();
|
||||
|
||||
// 1. Locate
|
||||
let cache_path = cache.base_dir.join("location.json");
|
||||
let location = locate::get_location(cache_path.to_str().unwrap_or("")).await?;
|
||||
eprintln!(" Geo: located at {:.4}N, {:.4}W", location.lat, location.lon);
|
||||
|
||||
// 2. Fetch satellite tiles
|
||||
let bbox = GeoBBox::from_center(&location, radius_m);
|
||||
let tile_list = tiles::fetch_area(&tiles::TileProvider::Sentinel2Cloudless, &bbox, 16, &cache).await?;
|
||||
eprintln!(" Geo: fetched {} satellite tiles", tile_list.len());
|
||||
|
||||
// 3. Fetch elevation
|
||||
let dem = terrain::fetch_elevation(&location, &cache).await?;
|
||||
let elevation = terrain::elevation_at(&dem, &location);
|
||||
eprintln!(" Geo: elevation {:.0}m ASL", elevation);
|
||||
|
||||
// 4. Fetch OSM buildings + roads
|
||||
let buildings = osm::fetch_buildings(&location, radius_m).await.unwrap_or_default();
|
||||
let roads = osm::fetch_roads(&location, radius_m).await.unwrap_or_default();
|
||||
eprintln!(" Geo: {} buildings, {} roads", buildings.len(), roads.len());
|
||||
|
||||
// 5. Build registration
|
||||
let mut reg_origin = location.clone();
|
||||
reg_origin.alt = elevation as f64;
|
||||
let registration = crate::register::auto_register(®_origin);
|
||||
|
||||
Ok(GeoScene {
|
||||
location: reg_origin,
|
||||
bbox,
|
||||
elevation_m: elevation,
|
||||
buildings,
|
||||
roads,
|
||||
tile_count: tile_list.len(),
|
||||
registration,
|
||||
last_updated: chrono::Utc::now().to_rfc3339(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Generate a text summary of the geo scene.
|
||||
pub fn summarize(scene: &GeoScene) -> String {
|
||||
let building_count = scene.buildings.len();
|
||||
let road_count = scene.roads.len();
|
||||
let road_names: Vec<&str> = scene.roads.iter()
|
||||
.filter_map(|r| match r {
|
||||
OsmFeature::Road { name, .. } => name.as_deref(),
|
||||
_ => None,
|
||||
})
|
||||
.take(3)
|
||||
.collect();
|
||||
|
||||
format!(
|
||||
"Location: {:.4}N, {:.4}W, elevation {:.0}m ASL. \
|
||||
{} buildings within view. {} roads nearby{}. \
|
||||
{} satellite tiles at zoom 16. Updated: {}.",
|
||||
scene.location.lat, scene.location.lon, scene.elevation_m,
|
||||
building_count, road_count,
|
||||
if road_names.is_empty() { String::new() }
|
||||
else { format!(" ({})", road_names.join(", ")) },
|
||||
scene.tile_count,
|
||||
&scene.last_updated[..10],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! wifi-densepose-geo — geospatial satellite integration for RuView.
|
||||
//!
|
||||
//! Provides: IP geolocation, satellite tile fetching (Sentinel-2),
|
||||
//! SRTM elevation, OSM buildings/roads, coordinate transforms,
|
||||
//! temporal change tracking, and brain memory integration.
|
||||
|
||||
pub mod types;
|
||||
pub mod coord;
|
||||
pub mod locate;
|
||||
pub mod cache;
|
||||
pub mod tiles;
|
||||
pub mod terrain;
|
||||
pub mod osm;
|
||||
pub mod register;
|
||||
pub mod fuse;
|
||||
pub mod brain;
|
||||
pub mod temporal;
|
||||
|
||||
pub use types::*;
|
||||
@@ -0,0 +1,40 @@
|
||||
//! IP geolocation — determine location from public IP.
|
||||
|
||||
use crate::types::GeoPoint;
|
||||
use anyhow::Result;
|
||||
|
||||
/// Locate by IP address (free, no API key).
|
||||
pub async fn locate_by_ip() -> Result<GeoPoint> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()?;
|
||||
|
||||
// Primary: ip-api.com (free, 45 req/min)
|
||||
let resp: serde_json::Value = client
|
||||
.get("http://ip-api.com/json/?fields=lat,lon,city,regionName,country")
|
||||
.send().await?
|
||||
.json().await?;
|
||||
|
||||
let lat = resp.get("lat").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let lon = resp.get("lon").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
|
||||
if lat == 0.0 && lon == 0.0 {
|
||||
anyhow::bail!("IP geolocation returned (0,0)");
|
||||
}
|
||||
|
||||
Ok(GeoPoint { lat, lon, alt: 0.0 })
|
||||
}
|
||||
|
||||
/// Get location with caching.
|
||||
pub async fn get_location(cache_path: &str) -> Result<GeoPoint> {
|
||||
// Check cache
|
||||
if let Ok(data) = std::fs::read_to_string(cache_path) {
|
||||
if let Ok(point) = serde_json::from_str::<GeoPoint>(&data) {
|
||||
return Ok(point);
|
||||
}
|
||||
}
|
||||
|
||||
let point = locate_by_ip().await?;
|
||||
let _ = std::fs::write(cache_path, serde_json::to_string(&point)?);
|
||||
Ok(point)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! OpenStreetMap data via Overpass API — buildings, roads, land use.
|
||||
|
||||
use crate::types::{GeoBBox, GeoPoint, OsmFeature};
|
||||
use anyhow::Result;
|
||||
|
||||
const OVERPASS_URL: &str = "https://overpass-api.de/api/interpreter";
|
||||
|
||||
/// Fetch buildings within radius of a point.
|
||||
pub async fn fetch_buildings(center: &GeoPoint, radius_m: f64) -> Result<Vec<OsmFeature>> {
|
||||
let bbox = GeoBBox::from_center(center, radius_m);
|
||||
let query = format!(
|
||||
r#"[out:json][timeout:10];way["building"]({},{},{},{});out body;>;out skel qt;"#,
|
||||
bbox.south, bbox.west, bbox.north, bbox.east
|
||||
);
|
||||
let resp = overpass_query(&query).await?;
|
||||
parse_buildings(&resp)
|
||||
}
|
||||
|
||||
/// Fetch roads within radius.
|
||||
pub async fn fetch_roads(center: &GeoPoint, radius_m: f64) -> Result<Vec<OsmFeature>> {
|
||||
let bbox = GeoBBox::from_center(center, radius_m);
|
||||
let query = format!(
|
||||
r#"[out:json][timeout:10];way["highway"]({},{},{},{});out body;>;out skel qt;"#,
|
||||
bbox.south, bbox.west, bbox.north, bbox.east
|
||||
);
|
||||
let resp = overpass_query(&query).await?;
|
||||
parse_roads(&resp)
|
||||
}
|
||||
|
||||
async fn overpass_query(query: &str) -> Result<serde_json::Value> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.user_agent("RuView/0.1")
|
||||
.build()?;
|
||||
|
||||
let resp = client.post(OVERPASS_URL)
|
||||
.form(&[("data", query)])
|
||||
.send().await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("Overpass API error: {}", resp.status());
|
||||
}
|
||||
Ok(resp.json().await?)
|
||||
}
|
||||
|
||||
fn parse_buildings(data: &serde_json::Value) -> Result<Vec<OsmFeature>> {
|
||||
let mut buildings = Vec::new();
|
||||
let mut nodes: std::collections::HashMap<u64, [f64; 2]> = std::collections::HashMap::new();
|
||||
|
||||
let elements = data.get("elements").and_then(|e| e.as_array()).cloned().unwrap_or_default();
|
||||
|
||||
// First pass: collect nodes
|
||||
for el in &elements {
|
||||
if el.get("type").and_then(|t| t.as_str()) == Some("node") {
|
||||
if let (Some(id), Some(lat), Some(lon)) = (
|
||||
el.get("id").and_then(|v| v.as_u64()),
|
||||
el.get("lat").and_then(|v| v.as_f64()),
|
||||
el.get("lon").and_then(|v| v.as_f64()),
|
||||
) {
|
||||
nodes.insert(id, [lat, lon]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: build ways
|
||||
for el in &elements {
|
||||
if el.get("type").and_then(|t| t.as_str()) != Some("way") { continue; }
|
||||
let tags = el.get("tags").cloned().unwrap_or(serde_json::json!({}));
|
||||
if tags.get("building").is_none() { continue; }
|
||||
|
||||
let node_ids = el.get("nodes").and_then(|n| n.as_array()).cloned().unwrap_or_default();
|
||||
let outline: Vec<[f64; 2]> = node_ids.iter()
|
||||
.filter_map(|id| id.as_u64().and_then(|id| nodes.get(&id).copied()))
|
||||
.collect();
|
||||
|
||||
if outline.len() < 3 { continue; }
|
||||
|
||||
let height = tags.get("height").and_then(|h| h.as_str())
|
||||
.and_then(|s| s.trim_end_matches('m').trim().parse::<f32>().ok())
|
||||
.or(Some(8.0)); // default building height
|
||||
|
||||
let name = tags.get("name").and_then(|n| n.as_str()).map(|s| s.to_string());
|
||||
|
||||
buildings.push(OsmFeature::Building { outline, height, name });
|
||||
}
|
||||
|
||||
Ok(buildings)
|
||||
}
|
||||
|
||||
fn parse_roads(data: &serde_json::Value) -> Result<Vec<OsmFeature>> {
|
||||
let mut roads = Vec::new();
|
||||
let mut nodes: std::collections::HashMap<u64, [f64; 2]> = std::collections::HashMap::new();
|
||||
|
||||
let elements = data.get("elements").and_then(|e| e.as_array()).cloned().unwrap_or_default();
|
||||
|
||||
for el in &elements {
|
||||
if el.get("type").and_then(|t| t.as_str()) == Some("node") {
|
||||
if let (Some(id), Some(lat), Some(lon)) = (
|
||||
el.get("id").and_then(|v| v.as_u64()),
|
||||
el.get("lat").and_then(|v| v.as_f64()),
|
||||
el.get("lon").and_then(|v| v.as_f64()),
|
||||
) {
|
||||
nodes.insert(id, [lat, lon]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for el in &elements {
|
||||
if el.get("type").and_then(|t| t.as_str()) != Some("way") { continue; }
|
||||
let tags = el.get("tags").cloned().unwrap_or(serde_json::json!({}));
|
||||
let highway = tags.get("highway").and_then(|h| h.as_str());
|
||||
if highway.is_none() { continue; }
|
||||
|
||||
let node_ids = el.get("nodes").and_then(|n| n.as_array()).cloned().unwrap_or_default();
|
||||
let path: Vec<[f64; 2]> = node_ids.iter()
|
||||
.filter_map(|id| id.as_u64().and_then(|id| nodes.get(&id).copied()))
|
||||
.collect();
|
||||
|
||||
if path.len() < 2 { continue; }
|
||||
|
||||
let name = tags.get("name").and_then(|n| n.as_str()).map(|s| s.to_string());
|
||||
|
||||
roads.push(OsmFeature::Road {
|
||||
path,
|
||||
road_type: highway.unwrap_or("unknown").to_string(),
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(roads)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! Geo-registration — maps local sensor coordinates to WGS84.
|
||||
|
||||
use crate::coord;
|
||||
use crate::types::{GeoPoint, GeoRegistration};
|
||||
|
||||
/// Auto-register using IP location (sensor at IP location, facing north).
|
||||
pub fn auto_register(ip_location: &GeoPoint) -> GeoRegistration {
|
||||
GeoRegistration {
|
||||
origin: ip_location.clone(),
|
||||
heading_deg: 0.0,
|
||||
scale: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Transform local point [x, y, z] to WGS84.
|
||||
pub fn local_to_wgs84(reg: &GeoRegistration, local: &[f32; 3]) -> GeoPoint {
|
||||
let heading_rad = reg.heading_deg.to_radians();
|
||||
let cos_h = heading_rad.cos();
|
||||
let sin_h = heading_rad.sin();
|
||||
|
||||
// Rotate local by heading (local X → East when heading=0)
|
||||
let east = (local[0] as f64 * cos_h - local[2] as f64 * sin_h) * reg.scale;
|
||||
let north = (local[0] as f64 * sin_h + local[2] as f64 * cos_h) * reg.scale;
|
||||
let up = local[1] as f64 * reg.scale;
|
||||
|
||||
coord::enu_to_wgs84(&[east, north, up], ®.origin)
|
||||
}
|
||||
|
||||
/// Transform WGS84 to local point.
|
||||
pub fn wgs84_to_local(reg: &GeoRegistration, geo: &GeoPoint) -> [f32; 3] {
|
||||
let enu = coord::wgs84_to_enu(geo, ®.origin);
|
||||
let heading_rad = (-reg.heading_deg).to_radians();
|
||||
let cos_h = heading_rad.cos();
|
||||
let sin_h = heading_rad.sin();
|
||||
|
||||
let x = ((enu[0] * cos_h - enu[1] * sin_h) / reg.scale) as f32;
|
||||
let z = ((enu[0] * sin_h + enu[1] * cos_h) / reg.scale) as f32;
|
||||
let y = (enu[2] / reg.scale) as f32;
|
||||
|
||||
[x, y, z]
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Temporal change tracking — detect changes in satellite/OSM/weather over time.
|
||||
|
||||
use crate::cache::TileCache;
|
||||
use crate::types::{GeoPoint, GeoScene};
|
||||
use anyhow::Result;
|
||||
|
||||
/// Fetch current weather (Open Meteo, free, no key).
|
||||
pub async fn fetch_weather(point: &GeoPoint) -> Result<WeatherData> {
|
||||
let url = format!(
|
||||
"https://api.open-meteo.com/v1/forecast?latitude={:.4}&longitude={:.4}¤t=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code",
|
||||
point.lat, point.lon
|
||||
);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()?;
|
||||
|
||||
let resp: serde_json::Value = client.get(&url).send().await?.json().await?;
|
||||
let current = resp.get("current").cloned().unwrap_or(serde_json::json!({}));
|
||||
|
||||
Ok(WeatherData {
|
||||
temperature_c: current.get("temperature_2m").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32,
|
||||
humidity_pct: current.get("relative_humidity_2m").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32,
|
||||
wind_speed_ms: current.get("wind_speed_10m").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32,
|
||||
weather_code: current.get("weather_code").and_then(|v| v.as_u64()).unwrap_or(0) as u16,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check for OSM changes since last fetch.
|
||||
pub async fn check_osm_changes(scene: &GeoScene, cache: &TileCache) -> Result<Vec<String>> {
|
||||
let mut changes = Vec::new();
|
||||
|
||||
let cache_key = "osm_building_count";
|
||||
let prev_count: usize = cache.get(cache_key)
|
||||
.and_then(|d| String::from_utf8(d).ok())
|
||||
.and_then(|s| s.trim().parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let current_count = scene.buildings.len();
|
||||
if prev_count > 0 && current_count != prev_count {
|
||||
let diff = current_count as i64 - prev_count as i64;
|
||||
changes.push(format!("Building count changed: {} → {} ({:+})", prev_count, current_count, diff));
|
||||
}
|
||||
|
||||
cache.put(cache_key, current_count.to_string().as_bytes())?;
|
||||
Ok(changes)
|
||||
}
|
||||
|
||||
/// Generate temporal summary for brain storage.
|
||||
pub fn temporal_summary(weather: &WeatherData, changes: &[String]) -> String {
|
||||
let weather_desc = match weather.weather_code {
|
||||
0 => "clear sky",
|
||||
1..=3 => "partly cloudy",
|
||||
45 | 48 => "foggy",
|
||||
51..=57 => "drizzle",
|
||||
61..=67 => "rain",
|
||||
71..=77 => "snow",
|
||||
80..=82 => "showers",
|
||||
95..=99 => "thunderstorm",
|
||||
_ => "unknown",
|
||||
};
|
||||
|
||||
let mut summary = format!(
|
||||
"Weather: {:.0}°C, {weather_desc}, humidity {:.0}%, wind {:.1}m/s.",
|
||||
weather.temperature_c, weather.humidity_pct, weather.wind_speed_ms,
|
||||
);
|
||||
|
||||
for change in changes {
|
||||
summary.push_str(&format!(" Change: {change}."));
|
||||
}
|
||||
|
||||
summary
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct WeatherData {
|
||||
pub temperature_c: f32,
|
||||
pub humidity_pct: f32,
|
||||
pub wind_speed_ms: f32,
|
||||
pub weather_code: u16,
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! SRTM DEM parser — elevation data from NASA 1-arcsecond HGT files.
|
||||
|
||||
use crate::cache::TileCache;
|
||||
use crate::types::{ElevationGrid, GeoPoint};
|
||||
use anyhow::Result;
|
||||
|
||||
/// Download and parse SRTM HGT for a location.
|
||||
pub async fn fetch_elevation(point: &GeoPoint, cache: &TileCache) -> Result<ElevationGrid> {
|
||||
let lat_int = point.lat.floor() as i32;
|
||||
let lon_int = point.lon.floor() as i32;
|
||||
let ns = if lat_int >= 0 { 'N' } else { 'S' };
|
||||
let ew = if lon_int >= 0 { 'E' } else { 'W' };
|
||||
let filename = format!("{}{:02}{}{:03}.hgt", ns, lat_int.unsigned_abs(), ew, lon_int.unsigned_abs());
|
||||
let cache_key = format!("srtm_{filename}");
|
||||
|
||||
if let Some(data) = cache.get(&cache_key) {
|
||||
return parse_hgt(&data, lat_int as f64, lon_int as f64);
|
||||
}
|
||||
|
||||
// Try OpenTopography SRTM (free, no auth)
|
||||
let url = format!(
|
||||
"https://portal.opentopography.org/API/globaldem?demtype=SRTMGL1&south={}&north={}&west={}&east={}&outputFormat=HGT",
|
||||
lat_int, lat_int + 1, lon_int, lon_int + 1
|
||||
);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()?;
|
||||
|
||||
match client.get(&url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let data = resp.bytes().await?.to_vec();
|
||||
cache.put(&cache_key, &data)?;
|
||||
parse_hgt(&data, lat_int as f64, lon_int as f64)
|
||||
}
|
||||
_ => {
|
||||
// Return flat terrain as fallback
|
||||
Ok(ElevationGrid {
|
||||
origin_lat: lat_int as f64,
|
||||
origin_lon: lon_int as f64,
|
||||
cell_size_deg: 1.0 / 3600.0,
|
||||
cols: 100, rows: 100,
|
||||
heights: vec![0.0; 10000],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse SRTM HGT binary (3601x3601 big-endian i16).
|
||||
pub fn parse_hgt(data: &[u8], origin_lat: f64, origin_lon: f64) -> Result<ElevationGrid> {
|
||||
let n_samples = data.len() / 2;
|
||||
let side = (n_samples as f64).sqrt() as usize;
|
||||
|
||||
let heights: Vec<f32> = data.chunks_exact(2)
|
||||
.map(|c| {
|
||||
let v = i16::from_be_bytes([c[0], c[1]]);
|
||||
if v == -32768 { 0.0 } else { v as f32 } // -32768 = void
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(ElevationGrid {
|
||||
origin_lat, origin_lon,
|
||||
cell_size_deg: 1.0 / (side - 1) as f64,
|
||||
cols: side, rows: side,
|
||||
heights,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get elevation at a specific point from a grid.
|
||||
pub fn elevation_at(grid: &ElevationGrid, point: &GeoPoint) -> f32 {
|
||||
grid.get(point.lat, point.lon).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Extract a small subgrid around a point.
|
||||
pub fn extract_subgrid(grid: &ElevationGrid, center: &GeoPoint, radius_m: f64) -> ElevationGrid {
|
||||
let radius_deg = radius_m / 111_320.0;
|
||||
let min_row = ((grid.origin_lat + (grid.rows as f64 * grid.cell_size_deg) - center.lat - radius_deg) / grid.cell_size_deg).max(0.0) as usize;
|
||||
let max_row = ((grid.origin_lat + (grid.rows as f64 * grid.cell_size_deg) - center.lat + radius_deg) / grid.cell_size_deg).min(grid.rows as f64) as usize;
|
||||
let min_col = ((center.lon - radius_deg - grid.origin_lon) / grid.cell_size_deg).max(0.0) as usize;
|
||||
let max_col = ((center.lon + radius_deg - grid.origin_lon) / grid.cell_size_deg).min(grid.cols as f64) as usize;
|
||||
|
||||
let rows = max_row.saturating_sub(min_row);
|
||||
let cols = max_col.saturating_sub(min_col);
|
||||
let mut heights = Vec::with_capacity(rows * cols);
|
||||
for r in min_row..max_row {
|
||||
for c in min_col..max_col {
|
||||
heights.push(grid.heights.get(r * grid.cols + c).copied().unwrap_or(0.0));
|
||||
}
|
||||
}
|
||||
|
||||
ElevationGrid {
|
||||
origin_lat: grid.origin_lat + (grid.rows - max_row) as f64 * grid.cell_size_deg,
|
||||
origin_lon: grid.origin_lon + min_col as f64 * grid.cell_size_deg,
|
||||
cell_size_deg: grid.cell_size_deg,
|
||||
cols, rows, heights,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Satellite tile fetcher — XYZ/TMS tile download with caching.
|
||||
|
||||
use crate::cache::TileCache;
|
||||
use crate::coord;
|
||||
use crate::types::{GeoBBox, RasterTile, TileCoord};
|
||||
use anyhow::Result;
|
||||
|
||||
/// Tile provider (all free, no API keys).
|
||||
pub enum TileProvider {
|
||||
/// Sentinel-2 cloudless mosaic (EOX, 10m, CC-BY-4.0)
|
||||
Sentinel2Cloudless,
|
||||
/// ESRI World Imagery (sub-meter, free tier)
|
||||
EsriWorldImagery,
|
||||
/// OpenStreetMap (map tiles, not satellite)
|
||||
Osm,
|
||||
}
|
||||
|
||||
impl TileProvider {
|
||||
pub fn url(&self, coord: &TileCoord) -> String {
|
||||
match self {
|
||||
Self::Sentinel2Cloudless => format!(
|
||||
"https://tiles.maps.eox.at/wmts/1.0.0/s2cloudless-2021_3857/default/g/{}/{}/{}.jpg",
|
||||
coord.z, coord.y, coord.x
|
||||
),
|
||||
Self::EsriWorldImagery => format!(
|
||||
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{}/{}/{}",
|
||||
coord.z, coord.y, coord.x
|
||||
),
|
||||
Self::Osm => format!(
|
||||
"https://tile.openstreetmap.org/{}/{}/{}.png",
|
||||
coord.z, coord.x, coord.y
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
Self::Sentinel2Cloudless => "sentinel2",
|
||||
Self::EsriWorldImagery => "esri",
|
||||
Self::Osm => "osm",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch a single tile with caching.
|
||||
pub async fn fetch_tile(provider: &TileProvider, coord: &TileCoord, cache: &TileCache) -> Result<RasterTile> {
|
||||
let cache_key = format!("tiles_{}_{}_{}.dat", coord.z, coord.x, coord.y);
|
||||
|
||||
if let Some(data) = cache.get(&cache_key) {
|
||||
return Ok(RasterTile { coord: coord.clone(), data, bounds: coord::tile_bounds(coord) });
|
||||
}
|
||||
|
||||
let url = provider.url(coord);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.user_agent("RuView/0.1 (https://github.com/ruvnet/RuView)")
|
||||
.build()?;
|
||||
|
||||
let resp = client.get(&url).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("Tile fetch failed: {} → {}", url, resp.status());
|
||||
}
|
||||
let data = resp.bytes().await?.to_vec();
|
||||
cache.put(&cache_key, &data)?;
|
||||
|
||||
Ok(RasterTile { coord: coord.clone(), data, bounds: coord::tile_bounds(coord) })
|
||||
}
|
||||
|
||||
/// Fetch all tiles covering a bounding box.
|
||||
pub async fn fetch_area(provider: &TileProvider, bbox: &GeoBBox, zoom: u8, cache: &TileCache) -> Result<Vec<RasterTile>> {
|
||||
let coords = coord::tiles_for_bbox(bbox, zoom);
|
||||
let mut tiles = Vec::with_capacity(coords.len());
|
||||
for c in &coords {
|
||||
match fetch_tile(provider, c, cache).await {
|
||||
Ok(t) => tiles.push(t),
|
||||
Err(e) => eprintln!(" Tile {}/{}/{} failed: {}", c.z, c.x, c.y, e),
|
||||
}
|
||||
}
|
||||
Ok(tiles)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Core geospatial types.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// WGS84 geographic coordinate.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct GeoPoint {
|
||||
pub lat: f64,
|
||||
pub lon: f64,
|
||||
pub alt: f64,
|
||||
}
|
||||
|
||||
/// Axis-aligned bounding box in WGS84.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct GeoBBox {
|
||||
pub south: f64,
|
||||
pub west: f64,
|
||||
pub north: f64,
|
||||
pub east: f64,
|
||||
}
|
||||
|
||||
impl GeoBBox {
|
||||
pub fn from_center(center: &GeoPoint, radius_m: f64) -> Self {
|
||||
let dlat = radius_m / 111_320.0;
|
||||
let dlon = radius_m / (111_320.0 * center.lat.to_radians().cos());
|
||||
Self {
|
||||
south: center.lat - dlat,
|
||||
west: center.lon - dlon,
|
||||
north: center.lat + dlat,
|
||||
east: center.lon + dlon,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// XYZ tile address.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct TileCoord {
|
||||
pub z: u8,
|
||||
pub x: u32,
|
||||
pub y: u32,
|
||||
}
|
||||
|
||||
/// Satellite raster tile.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RasterTile {
|
||||
pub coord: TileCoord,
|
||||
pub data: Vec<u8>,
|
||||
pub bounds: GeoBBox,
|
||||
}
|
||||
|
||||
/// Elevation grid from SRTM DEM.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ElevationGrid {
|
||||
pub origin_lat: f64,
|
||||
pub origin_lon: f64,
|
||||
pub cell_size_deg: f64,
|
||||
pub cols: usize,
|
||||
pub rows: usize,
|
||||
pub heights: Vec<f32>,
|
||||
}
|
||||
|
||||
impl ElevationGrid {
|
||||
pub fn get(&self, lat: f64, lon: f64) -> Option<f32> {
|
||||
let row = ((self.origin_lat + (self.rows as f64 * self.cell_size_deg) - lat) / self.cell_size_deg) as usize;
|
||||
let col = ((lon - self.origin_lon) / self.cell_size_deg) as usize;
|
||||
if row < self.rows && col < self.cols {
|
||||
Some(self.heights[row * self.cols + col])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenStreetMap feature.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum OsmFeature {
|
||||
Building {
|
||||
outline: Vec<[f64; 2]>,
|
||||
height: Option<f32>,
|
||||
name: Option<String>,
|
||||
},
|
||||
Road {
|
||||
path: Vec<[f64; 2]>,
|
||||
road_type: String,
|
||||
name: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Geo-registration transform.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct GeoRegistration {
|
||||
pub origin: GeoPoint,
|
||||
pub heading_deg: f64,
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
impl Default for GeoRegistration {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
origin: GeoPoint { lat: 0.0, lon: 0.0, alt: 0.0 },
|
||||
heading_deg: 0.0,
|
||||
scale: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete geo scene.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct GeoScene {
|
||||
pub location: GeoPoint,
|
||||
pub bbox: GeoBBox,
|
||||
pub elevation_m: f32,
|
||||
pub buildings: Vec<OsmFeature>,
|
||||
pub roads: Vec<OsmFeature>,
|
||||
pub tile_count: usize,
|
||||
pub registration: GeoRegistration,
|
||||
pub last_updated: String,
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use ruview_geo::*;
|
||||
use ruview_geo::coord;
|
||||
|
||||
#[test]
|
||||
fn test_haversine() {
|
||||
let toronto = GeoPoint { lat: 43.6532, lon: -79.3832, alt: 0.0 };
|
||||
let ottawa = GeoPoint { lat: 45.4215, lon: -75.6972, alt: 0.0 };
|
||||
let dist = coord::haversine(&toronto, &ottawa);
|
||||
assert!((dist - 353_000.0).abs() < 5_000.0, "Toronto-Ottawa ~353km, got {:.0}m", dist);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wgs84_to_enu() {
|
||||
let origin = GeoPoint { lat: 43.0, lon: -79.0, alt: 100.0 };
|
||||
let point = GeoPoint { lat: 43.001, lon: -79.0, alt: 100.0 };
|
||||
let enu = coord::wgs84_to_enu(&point, &origin);
|
||||
assert!((enu[1] - 111.0).abs() < 5.0, "0.001 deg lat ~111m north, got {:.1}m", enu[1]);
|
||||
assert!(enu[0].abs() < 1.0, "same longitude should have ~0 east, got {:.1}m", enu[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enu_roundtrip() {
|
||||
let origin = GeoPoint { lat: 43.6532, lon: -79.3832, alt: 76.0 };
|
||||
let local = [100.0, 200.0, 5.0]; // 100m east, 200m north, 5m up
|
||||
let geo = coord::enu_to_wgs84(&local, &origin);
|
||||
let back = coord::wgs84_to_enu(&geo, &origin);
|
||||
assert!((back[0] - local[0]).abs() < 0.01);
|
||||
assert!((back[1] - local[1]).abs() < 0.01);
|
||||
assert!((back[2] - local[2]).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tile_coords() {
|
||||
let tile = coord::wgs84_to_tile(43.6532, -79.3832, 16);
|
||||
assert!(tile.x > 0 && tile.y > 0);
|
||||
assert_eq!(tile.z, 16);
|
||||
let bounds = coord::tile_bounds(&tile);
|
||||
assert!(bounds.south < 43.66 && bounds.north > 43.64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tiles_for_bbox() {
|
||||
let bbox = GeoBBox::from_center(
|
||||
&GeoPoint { lat: 43.6532, lon: -79.3832, alt: 0.0 },
|
||||
500.0,
|
||||
);
|
||||
let tiles = coord::tiles_for_bbox(&bbox, 16);
|
||||
assert!(tiles.len() >= 4 && tiles.len() <= 25, "500m radius should need 4-25 tiles, got {}", tiles.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_geo_bbox_from_center() {
|
||||
let center = GeoPoint { lat: 43.0, lon: -79.0, alt: 0.0 };
|
||||
let bbox = GeoBBox::from_center(¢er, 1000.0);
|
||||
assert!(bbox.south < 43.0 && bbox.north > 43.0);
|
||||
assert!(bbox.west < -79.0 && bbox.east > -79.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hgt_parse() {
|
||||
// Create minimal 3x3 HGT data (big-endian i16)
|
||||
let mut data = Vec::new();
|
||||
for h in [100i16, 110, 120, 105, 115, 125, 110, 120, 130] {
|
||||
data.extend_from_slice(&h.to_be_bytes());
|
||||
}
|
||||
let grid = ruview_geo::terrain::parse_hgt(&data, 43.0, -79.0).unwrap();
|
||||
assert_eq!(grid.heights[0], 100.0);
|
||||
assert_eq!(grid.heights[4], 115.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registration() {
|
||||
let origin = GeoPoint { lat: 43.6532, lon: -79.3832, alt: 76.0 };
|
||||
let reg = ruview_geo::register::auto_register(&origin);
|
||||
|
||||
let local = [10.0f32, 0.0, 20.0]; // 10m east, 20m forward
|
||||
let geo = ruview_geo::register::local_to_wgs84(®, &local);
|
||||
assert!((geo.lat - origin.lat).abs() < 0.001);
|
||||
assert!((geo.lon - origin.lon).abs() < 0.001);
|
||||
|
||||
let back = ruview_geo::register::wgs84_to_local(®, &geo);
|
||||
assert!((back[0] - local[0]).abs() < 0.1);
|
||||
assert!((back[2] - local[2]).abs() < 0.1);
|
||||
}
|
||||
Reference in New Issue
Block a user