Squashed from work: - rtsmap: one seeded generator for tiled strategy maps - map: bake a runnable Amsterdam test map from inside the route app - route: the first-run test map starts itself, and its buttons are guarded - map: the test map bakes its road faces too, from the same shared pass - map_tiles: mkmap-extract — the weave is reversible, world-cells reconstructible from world.mkmap - route: the local dispatcher rides the hub — its copied engine is deleted, limits preserved (aicore P1) - route + converse: off makepad_ai — the Agent seam moves to converse, route's cloud dispatcher rides the hub's Claude p - ai-hub: chats run the machine election — route to a serving holder, wait on a loading one, claim and publish when open - libs: the zero-warning sweep — stitch casts say what they mean, xatlas keeps upstream's surface quietly
344 lines
12 KiB
Rust
344 lines
12 KiB
Rust
//! First run with no map data: fetch Amsterdam and bake it, in the window.
|
|
//!
|
|
//! The maps this app normally draws are built by `tools/download_map.sh`
|
|
//! and measured in tens of gigabytes. Someone who has just cloned the repo
|
|
//! has none of them, and until now got a black window and a log line about
|
|
//! a missing archive. So: on a first run with nothing on disk, the app
|
|
//! downloads one city extract and bakes it into a real map — tiles, a
|
|
//! routing graph and a search index — while showing what it is doing.
|
|
//!
|
|
//! Split of labour, and why:
|
|
//!
|
|
//! * The **download** runs on the platform's HTTP stack (`cx.http_request`),
|
|
//! which streams the body and reports `HttpProgress` as it goes on every
|
|
//! OS we ship. That is where the honest byte counter comes from.
|
|
//! * The **bake** runs on one worker thread through
|
|
//! [`makepad_map_build::testmap::bake`] — the same passes the
|
|
//! `makepad-map-tiles testmap` CLI runs, with a progress sink installed so
|
|
//! their commentary lands in this window instead of a terminal nobody is
|
|
//! watching.
|
|
//!
|
|
//! Both halves report into [`TestMapBuild`], which the app polls like any
|
|
//! other worker (`nav_data`, radar, wind).
|
|
|
|
use makepad_map_build::progress::{Report, SinkGuard};
|
|
use makepad_map_build::testmap::{self, BakeOptions, NoFetch, TestMapPaths};
|
|
use makepad_widgets::*;
|
|
use std::fs;
|
|
use std::sync::Mutex;
|
|
|
|
/// Lines kept in the popup's log pane. Enough to show the current pass and
|
|
/// how it got there, few enough that the panel never grows.
|
|
const LOG_LINES: usize = 7;
|
|
|
|
/// The archives a fully provisioned machine has (the paths the MapView is
|
|
/// declared with). Any one of them present means this is not a first run
|
|
/// and nothing should be offered.
|
|
const PRODUCTION_ARCHIVES: [&str; 3] = [
|
|
"local/maps/world.mkmap",
|
|
"local/maps/europe-base-br-faces.mbtiles",
|
|
"local/maps/europe-shortbread.mbtiles",
|
|
];
|
|
|
|
/// True when this machine already has a real map to draw.
|
|
pub fn production_archive_present() -> bool {
|
|
PRODUCTION_ARCHIVES
|
|
.iter()
|
|
.any(|path| std::path::Path::new(path).exists())
|
|
}
|
|
|
|
/// What the worker thread sends back.
|
|
enum BakeMsg {
|
|
Progress(Report),
|
|
Done,
|
|
Failed(String),
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub enum Stage {
|
|
/// Nothing to do — the app has map data.
|
|
Idle,
|
|
/// Waiting for the user to say go.
|
|
Offered,
|
|
Fetching { loaded: u64, total: u64 },
|
|
Baking,
|
|
Done,
|
|
Failed(String),
|
|
}
|
|
|
|
pub struct TestMapBuild {
|
|
pub paths: TestMapPaths,
|
|
pub stage: Stage,
|
|
/// Headline: the pass now running, in its own words.
|
|
pub headline: String,
|
|
/// The last few detail lines under that headline.
|
|
pub log: Vec<String>,
|
|
/// Whole-recipe progress, 0..1.
|
|
pub fraction: f32,
|
|
request_id: Option<LiveId>,
|
|
rx: ToUIReceiver<BakeMsg>,
|
|
/// Held for the life of the bake; dropping it releases the global sink.
|
|
sink: Option<SinkGuard>,
|
|
}
|
|
|
|
impl Default for TestMapBuild {
|
|
fn default() -> Self {
|
|
Self {
|
|
paths: TestMapPaths::amsterdam(),
|
|
stage: Stage::Idle,
|
|
headline: String::new(),
|
|
log: Vec::new(),
|
|
fraction: 0.0,
|
|
request_id: None,
|
|
rx: ToUIReceiver::default(),
|
|
sink: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TestMapBuild {
|
|
/// True while the popup should be on screen.
|
|
pub fn is_active(&self) -> bool {
|
|
self.stage != Stage::Idle
|
|
}
|
|
|
|
/// Waiting to be started (or restarted after a failure): the only
|
|
/// states in which the start button does anything.
|
|
pub fn can_start(&self) -> bool {
|
|
matches!(self.stage, Stage::Offered | Stage::Failed(_))
|
|
}
|
|
|
|
/// Freshly offered and not yet running.
|
|
pub fn is_offered(&self) -> bool {
|
|
self.stage == Stage::Offered
|
|
}
|
|
|
|
/// True while the bake is running and must not be started twice.
|
|
pub fn is_running(&self) -> bool {
|
|
matches!(self.stage, Stage::Fetching { .. } | Stage::Baking)
|
|
}
|
|
|
|
/// Offer the bake when this machine has no map to draw. Production
|
|
/// archives win: someone with the real maps must never be asked to
|
|
/// download a test one.
|
|
pub fn offer_if_no_map(&mut self, production_archive_present: bool) {
|
|
if production_archive_present || self.paths.is_complete() {
|
|
return;
|
|
}
|
|
self.stage = Stage::Offered;
|
|
self.headline = "No map data on this machine".to_string();
|
|
self.log = vec![
|
|
"Building an Amsterdam test map: ~143 MB download, then a couple".to_string(),
|
|
"of minutes of baking. Tiles with baked road faces, a routing".to_string(),
|
|
"graph and a search index land under local/maps.".to_string(),
|
|
];
|
|
}
|
|
|
|
/// Start (or resume) the recipe: download the extract if it is not
|
|
/// already here, then bake.
|
|
pub fn start(&mut self, cx: &mut Cx) {
|
|
if !self.can_start() {
|
|
return;
|
|
}
|
|
self.fraction = 0.0;
|
|
if self.paths.pbf.is_file() {
|
|
self.start_bake();
|
|
return;
|
|
}
|
|
self.stage = Stage::Fetching { loaded: 0, total: testmap::AMSTERDAM_PBF_APPROX_BYTES };
|
|
self.headline = "Downloading Amsterdam extract".to_string();
|
|
self.push_log(format!("GET {}", testmap::AMSTERDAM_PBF_URL));
|
|
let request_id = LiveId::unique();
|
|
self.request_id = Some(request_id);
|
|
let request = HttpRequest::new(testmap::AMSTERDAM_PBF_URL.to_string(), HttpMethod::GET);
|
|
cx.http_request(request_id, request);
|
|
}
|
|
|
|
/// The popup's second button: cancel a download, or put the offer away
|
|
/// (it comes back on the next launch — the machine still has no map),
|
|
/// or close the popup on a finished bake.
|
|
pub fn dismiss(&mut self) {
|
|
match self.stage {
|
|
Stage::Fetching { .. } => self.cancel(),
|
|
_ => self.stage = Stage::Idle,
|
|
}
|
|
}
|
|
|
|
/// Give up on a download in flight. The bake itself has no cancel: its
|
|
/// passes are minutes of straight-line CPU work, and killing one
|
|
/// mid-write is what the scratch store's resume markers are for.
|
|
pub fn cancel(&mut self) {
|
|
if let Stage::Fetching { .. } = self.stage {
|
|
self.request_id = None;
|
|
self.stage = Stage::Offered;
|
|
self.headline = "Download cancelled".to_string();
|
|
}
|
|
}
|
|
|
|
/// True when this response belongs to the extract download.
|
|
pub fn owns_request(&self, request_id: LiveId) -> bool {
|
|
self.request_id == Some(request_id)
|
|
}
|
|
|
|
pub fn handle_http_progress(&mut self, request_id: LiveId, progress: &HttpProgress) -> bool {
|
|
if !self.owns_request(request_id) {
|
|
return false;
|
|
}
|
|
let total = if progress.total > 0 {
|
|
progress.total
|
|
} else {
|
|
testmap::AMSTERDAM_PBF_APPROX_BYTES
|
|
};
|
|
self.stage = Stage::Fetching { loaded: progress.loaded, total };
|
|
self.fraction =
|
|
testmap::overall_fraction("fetch", progress.loaded as f32 / total.max(1) as f32);
|
|
true
|
|
}
|
|
|
|
pub fn handle_http_response(&mut self, request_id: LiveId, response: &HttpResponse) -> bool {
|
|
if !self.owns_request(request_id) {
|
|
return false;
|
|
}
|
|
self.request_id = None;
|
|
if response.status_code != 200 {
|
|
self.fail(format!("download failed: HTTP {}", response.status_code));
|
|
return true;
|
|
}
|
|
let body = response.body.as_deref().unwrap_or(&[]);
|
|
if body.len() < 1_000_000 {
|
|
self.fail(format!("download failed: {} bytes is not an extract", body.len()));
|
|
return true;
|
|
}
|
|
// Landed under a partial name and renamed, so an interrupted run
|
|
// never leaves something that looks like a finished extract.
|
|
let part = self.paths.pbf.with_extension("pbf.part");
|
|
if let Some(dir) = part.parent() {
|
|
if let Err(err) = fs::create_dir_all(dir) {
|
|
self.fail(format!("create {}: {err}", dir.display()));
|
|
return true;
|
|
}
|
|
}
|
|
if let Err(err) = fs::write(&part, body) {
|
|
self.fail(format!("write {}: {err}", part.display()));
|
|
return true;
|
|
}
|
|
if let Err(err) = fs::rename(&part, &self.paths.pbf) {
|
|
self.fail(format!("rename {}: {err}", part.display()));
|
|
return true;
|
|
}
|
|
self.push_log(format!("extract saved: {:.0} MB", body.len() as f64 / 1.0e6));
|
|
self.start_bake();
|
|
true
|
|
}
|
|
|
|
pub fn handle_http_error(&mut self, request_id: LiveId, err: &str) -> bool {
|
|
if !self.owns_request(request_id) {
|
|
return false;
|
|
}
|
|
self.request_id = None;
|
|
self.fail(format!("download failed: {err}"));
|
|
true
|
|
}
|
|
|
|
/// Drain worker messages. Returns true when something changed, and
|
|
/// `Some(true)` finishing means the map is ready to be pointed at.
|
|
pub fn poll(&mut self) -> bool {
|
|
let mut changed = false;
|
|
while let Ok(msg) = self.rx.try_recv() {
|
|
changed = true;
|
|
match msg {
|
|
BakeMsg::Progress(report) => {
|
|
if let Some(fraction) = report.fraction {
|
|
self.fraction = testmap::overall_fraction(report.stage, fraction);
|
|
}
|
|
if report.line.is_empty() {
|
|
continue;
|
|
}
|
|
if report.headline {
|
|
self.headline = report.line;
|
|
} else {
|
|
self.push_log(report.line);
|
|
}
|
|
}
|
|
BakeMsg::Done => {
|
|
self.sink = None;
|
|
self.stage = Stage::Done;
|
|
self.fraction = 1.0;
|
|
self.headline = "Test map ready".to_string();
|
|
}
|
|
BakeMsg::Failed(error) => {
|
|
self.sink = None;
|
|
self.fail(error);
|
|
}
|
|
}
|
|
}
|
|
changed
|
|
}
|
|
|
|
fn start_bake(&mut self) {
|
|
self.stage = Stage::Baking;
|
|
self.headline = "Baking tiles".to_string();
|
|
let sender = Mutex::new(self.rx.sender());
|
|
// Installed for the whole bake: every pass reports through it, from
|
|
// whichever worker thread it happens to be on.
|
|
self.sink = SinkGuard::install(move |report| {
|
|
if let Ok(sender) = sender.lock() {
|
|
let _ = sender.send(BakeMsg::Progress(report));
|
|
}
|
|
});
|
|
if self.sink.is_none() {
|
|
self.fail("a bake is already running in this process".to_string());
|
|
return;
|
|
}
|
|
let mut options = BakeOptions::amsterdam();
|
|
options.paths = self.paths.clone();
|
|
let done = self.rx.sender();
|
|
std::thread::spawn(move || {
|
|
// NoFetch: the extract is on disk before this thread starts —
|
|
// downloading is the window's job, where progress comes free.
|
|
//
|
|
// Caught, not propagated: the face pass reports a corrupt tile
|
|
// by panicking inside its worker pool, and an uncaught panic
|
|
// here would take this thread out with the channel still open —
|
|
// a popup frozen at 63% forever. A failed bake must SAY so.
|
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
testmap::bake(&options, &mut NoFetch)
|
|
}))
|
|
.unwrap_or_else(|_| Err("bake panicked — see the log".to_string()));
|
|
let _ = done.send(match result {
|
|
Ok(()) => BakeMsg::Done,
|
|
Err(error) => BakeMsg::Failed(error),
|
|
});
|
|
});
|
|
}
|
|
|
|
fn fail(&mut self, error: String) {
|
|
self.push_log(error.clone());
|
|
self.headline = "Test map build failed".to_string();
|
|
self.stage = Stage::Failed(error);
|
|
}
|
|
|
|
fn push_log(&mut self, line: String) {
|
|
self.log.push(line.trim_end().to_string());
|
|
if self.log.len() > LOG_LINES {
|
|
self.log.remove(0);
|
|
}
|
|
}
|
|
|
|
/// The line under the bar: bytes while downloading, the pass otherwise.
|
|
pub fn status_line(&self) -> String {
|
|
match &self.stage {
|
|
Stage::Fetching { loaded, total } => format!(
|
|
"{:.0} of {:.0} MB",
|
|
*loaded as f64 / 1.0e6,
|
|
*total as f64 / 1.0e6
|
|
),
|
|
Stage::Baking => format!("{:.0}%", self.fraction * 100.0),
|
|
Stage::Done => format!(
|
|
"{:.1} GB under local/maps",
|
|
self.paths.bytes_on_disk() as f64 / 1.0e9
|
|
),
|
|
_ => String::new(),
|
|
}
|
|
}
|
|
}
|