diff --git a/AGENTS.md b/AGENTS.md index cc43c11..bc2a6ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ egui front end, headless screenshots for UI work. | L1 | `data` | features, typed attributes, GeoJSON/CSV/Shapefile I/O, SQL where clauses, rasters (GeoTIFF/ASCII grid, EPSG reprojection) | | L2 | `map`, `analysis` | layers, symbology, labels, camera, basemaps; geoprocessing tools | | L3 | `render` | tiny-skia map renderer, label placement, PNG export, font discovery | -| L3 | `geolibre` | the GeoLibre/Whitebox tool suite as geoprocessing tools, run through the optional `geolibre` runner executable (native only, behind the engine's `geolibre` feature) | +| L3 | `geolibre` | the GeoLibre/Whitebox tool suite as geoprocessing tools (engine feature `geolibre`): run by the optional `geolibre` program natively, by geolibre-rust's WASI runner in the browser | | L4 | `engine` | `Session`, undo, command registry, ribbon feature catalog, sample project | | L6 | `ui-egui` | ribbon, panes, map view, attribute table, dialogs (swappable) | | apps | `rustgis`, `rustgis-cli`, `rustgis-web` | desktop app, command line, browser (WebAssembly) | @@ -29,8 +29,9 @@ egui front end, headless screenshots for UI work. and CLI dispatch by id. Commands never open dialogs. - **Never break wasm**: everything below `apps/` must build for `wasm32-unknown-unknown` (CI runs `cargo build --target wasm32-unknown-unknown -p rustgis-web`). Use `web_time`, not - `std::time`; file access goes through `Services` on the web. The one exception is - `rustgis-geolibre`, which only the desktop app and CLI enable (engine feature `geolibre`). + `std::time`; file access goes through `Services` on the web. `rustgis-geolibre` builds for + both: natively it runs the `geolibre` program; in the browser the web app runs geolibre-rust's + WASI runner asynchronously (`external_start` → `Services::geolibre_run` → `gp.finish`). - **Interface text is translatable**: wrap user-visible literals in `tl!("…")`, use `i18n::fmt("… {name} …", &[("name", …)])` instead of `format!` for messages, and pass labels to the ribbon widgets (they translate). Then add the new strings to every catalog in diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index ad6bb61..fb5a765 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -12,8 +12,9 @@ app. The GeoLibre tool suite ([geolibre-rust](https://github.com/opengeos/geolibre-rust), MIT, built on [whitebox-wasm](https://github.com/opengeos/whitebox-wasm), MIT) is not part of RustGIS: the -desktop app and command line run its separately downloaded `geolibre` runner, and its tool -names and descriptions come from the runner's manifests. +desktop app and command line run its separately downloaded `geolibre` runner, and the web app +loads its WebAssembly runner (the `geolibre-wasm` npm package, from jsDelivr) when asked. Its +tool names and descriptions come from the runner's manifests. Icons are original vector drawings in code (`crates/ui-egui/src/icons.rs`). Fonts are not bundled: the interface uses an installed system sans-serif when one is found, otherwise the diff --git a/Cargo.lock b/Cargo.lock index e692e5e..12a25e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3339,6 +3339,7 @@ dependencies = [ "js-sys", "log", "rfd", + "rustgis-engine", "rustgis-ui-egui", "wasm-bindgen", "wasm-bindgen-futures", diff --git a/README.md b/README.md index dc94cb8..b96dad3 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Every screenshot is RustGIS itself, rendered offscreen by `cargo run -p rustgis- | **Labeling** | Per-layer labels with field, size, colour, bold and halo; collision-free placement | | **Selection** | Click and box select (add/remove), Select By Attributes with live SQL validation, Select By Location, switch/clear/zoom to selection, layer from selection | | **Editing** | Create points, lines and polygons; delete; edit cells in the attribute table; add, delete and calculate fields; undo/redo for everything | -| **Analysis** | Buffer, Clip, Erase, Dissolve, Minimum Bounding Geometry, Feature To Point, Simplify, Summarize Within, Calculate Geometry, Summary Statistics, Export Features; plus 1,000+ raster, vector, LiDAR, hydrology and terrain tools from the [GeoLibre](https://github.com/opengeos/geolibre-rust) suite (desktop and command line, with the optional GeoLibre runner download) | +| **Analysis** | Buffer, Clip, Erase, Dissolve, Minimum Bounding Geometry, Feature To Point, Simplify, Summarize Within, Calculate Geometry, Summary Statistics, Export Features; plus 1,000+ raster, vector, LiDAR, hydrology and terrain tools from the [GeoLibre](https://github.com/opengeos/geolibre-rust) suite (an optional download: desktop, command line and web app) | | **Inquiry** | Identify pop-ups, geodesic distance and area measurement, live coordinates (DD or DMS) | | **Share** | PNG export with labels and a scale bar at any DPI; export layers to GeoJSON/CSV | | **Productivity** | Command search (Ctrl+K), keyboard shortcuts, dark mode, Catalog pane with folder browsing | diff --git a/apps/rustgis-web/Cargo.toml b/apps/rustgis-web/Cargo.toml index 9611e55..76b3a54 100644 --- a/apps/rustgis-web/Cargo.toml +++ b/apps/rustgis-web/Cargo.toml @@ -11,6 +11,8 @@ workspace = true [dependencies] rustgis-ui-egui = { workspace = true } +# The GeoLibre tools, run in the browser by geolibre-rust's WASI runner. +rustgis-engine = { workspace = true, features = ["geolibre"] } egui = { workspace = true } log = { workspace = true } diff --git a/apps/rustgis-web/src/geolibre.rs b/apps/rustgis-web/src/geolibre.rs new file mode 100644 index 0000000..0d3eb02 --- /dev/null +++ b/apps/rustgis-web/src/geolibre.rs @@ -0,0 +1,111 @@ +//! The GeoLibre tools in the browser: geolibre-rust's WASI runner (the `geolibre-wasm` npm +//! package), loaded on demand from jsDelivr and run in an in-memory `/work` folder. + +use rustgis_ui_egui::Slot; +use rustgis_ui_egui::rustgis_engine::cmd::gp::{ExternalOutput, ExternalRun}; +use wasm_bindgen::JsCast as _; +use wasm_bindgen::prelude::*; + +// The runner's version is pinned: the tool forms and the runner must come from the same +// release. The JS module is jsDelivr's ES-module build of the package; the WASI binary is +// passed explicitly, since that build can't find it beside itself. +// +// Trust: this runs code from jsDelivr in the app's origin, only after the user asks for the +// tools (Load GeoLibre tools). A dynamic import can't carry Subresource Integrity, and the +// runner (~25 MB) is too large to ship with the app, so the pinned version and HTTPS are the +// safeguards: the same trust as installing the npm package. +#[wasm_bindgen(inline_js = r#" +const BASE = "https://cdn.jsdelivr.net/npm/geolibre-wasm@1.6.0"; +let ready = null; +function tools() { + if (!ready) { + ready = (async () => { + const mod = await import(`${BASE}/tools.mjs/+esm`); + await mod.initTools(`${BASE}/geolibre-cli.wasm`); + return mod; + })(); + // A failed download can be retried. + ready.catch(() => { ready = null; }); + } + return ready; +} +export async function geolibre_manifests() { + const t = await tools(); + return JSON.stringify(await t.listManifests()); +} +export async function geolibre_run(tool, argv, names, datas) { + const t = await tools(); + const input = {}; + for (let i = 0; i < names.length; i++) input[names[i]] = datas[i]; + const r = await t.runTool(tool, { args: argv, input }); + const out = Object.entries(r.files); + return { exitCode: r.exitCode, stdout: r.stdout, names: out.map((e) => e[0]), datas: out.map((e) => e[1]) }; +} +"#)] +extern "C" { + #[wasm_bindgen(catch)] + async fn geolibre_manifests() -> Result; + #[wasm_bindgen(catch)] + async fn geolibre_run(tool: &str, argv: js_sys::Array, names: js_sys::Array, datas: js_sys::Array) -> Result; +} + +fn js_error(e: &JsValue) -> String { + match e.dyn_ref::() { + Some(err) => String::from(err.message()), + None => e.as_string().unwrap_or_else(|| format!("{e:?}")), + } +} + +fn get(obj: &JsValue, key: &str) -> JsValue { + js_sys::Reflect::get(obj, &JsValue::from_str(key)).unwrap_or(JsValue::UNDEFINED) +} + +fn strings(v: &JsValue) -> Vec { + js_sys::Array::from(v).iter().filter_map(|s| s.as_string()).collect() +} + +/// Lists the tools' manifests into `slot`, downloading the runner the first time. +pub fn manifests(slot: Slot>, ctx: egui::Context) { + wasm_bindgen_futures::spawn_local(async move { + let r = match geolibre_manifests().await { + Ok(v) => v.as_string().ok_or_else(|| "the GeoLibre runner returned no tool list".to_string()), + Err(e) => Err(format!("couldn't load the GeoLibre tools: {}", js_error(&e))), + }; + if let Ok(mut s) = slot.lock() { + *s = Some(r); + } + ctx.request_repaint(); + }); +} + +/// Runs one tool and puts its new files and printed lines (or its error) in `slot`. +pub fn run(job: ExternalRun, slot: Slot, ctx: egui::Context) { + wasm_bindgen_futures::spawn_local(async move { + let argv: js_sys::Array = job.argv.iter().map(|a| JsValue::from_str(a)).collect(); + let names: js_sys::Array = job.files.iter().map(|(n, _)| JsValue::from_str(n)).collect(); + let datas: js_sys::Array = job.files.iter().map(|(_, b)| JsValue::from(js_sys::Uint8Array::from(b.as_slice()))).collect(); + let r = match geolibre_run(&job.tool, argv, names, datas).await { + Err(e) => Err(js_error(&e)), + Ok(out) => { + let lines = strings(&get(&out, "stdout")); + if get(&out, "exitCode").as_f64().unwrap_or(1.0) != 0.0 { + // The tool's error is the end of what it printed. + let tail: Vec<&String> = lines.iter().rev().take(12).collect(); + let msg = tail.into_iter().rev().map(String::as_str).collect::>().join("\n"); + Err(if msg.trim().is_empty() { "the tool stopped unexpectedly".to_string() } else { msg }) + } else { + let files = strings(&get(&out, "names")) + .into_iter() + .zip(js_sys::Array::from(&get(&out, "datas")).iter()) + .map(|(n, d)| (n, js_sys::Uint8Array::new(&d).to_vec())) + .collect(); + Ok((files, lines)) + } + } + }; + if let Ok(mut s) = slot.lock() { + *s = Some(r); + } + ctx.request_repaint(); + }); +} diff --git a/apps/rustgis-web/src/main.rs b/apps/rustgis-web/src/main.rs index b5eaf81..c843edb 100644 --- a/apps/rustgis-web/src/main.rs +++ b/apps/rustgis-web/src/main.rs @@ -7,9 +7,12 @@ //! Differences from the desktop app: Open and Add Data use the browser's file picker (GeoJSON, //! CSV and `.rgisx`; Shapefiles need the desktop app), files can be dropped on the page, and //! Save and Export download. URL flags: `?webgl` forces WebGL2; `?blank` starts an empty project; -//! `?lang=de` sets the interface language. +//! `?lang=de` sets the interface language. The GeoLibre tools run in the browser through +//! geolibre-rust's WASI runner, loaded on demand (see `geolibre.rs`). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(target_arch = "wasm32")] +mod geolibre; #[cfg(target_arch = "wasm32")] mod web; diff --git a/apps/rustgis-web/src/web.rs b/apps/rustgis-web/src/web.rs index dd8cb6c..82b9490 100644 --- a/apps/rustgis-web/src/web.rs +++ b/apps/rustgis-web/src/web.rs @@ -138,6 +138,7 @@ async fn fetch_bytes(url: &str) -> Result, String> { fn services(inbox: Inbox, font_inbox: Inbox, ctx: egui::Context) -> Services { let open_inbox = inbox.clone(); let font_ctx = ctx.clone(); + let (gl_list_ctx, gl_run_ctx) = (ctx.clone(), ctx.clone()); let font_sink = font_inbox.clone(); Services { load_pref: Some(Box::new(|key: &str| storage().and_then(|s| s.get_item(&format!("rustgis.{key}")).ok().flatten()))), @@ -187,6 +188,8 @@ fn services(inbox: Inbox, font_inbox: Inbox, ctx: egui::Context) -> Services { } })), inbox: Some(inbox), + geolibre_manifests: Some(Box::new(move |slot| crate::geolibre::manifests(slot, gl_list_ctx.clone()))), + geolibre_run: Some(Box::new(move |job, slot| crate::geolibre::run(job, slot, gl_run_ctx.clone()))), } } diff --git a/crates/analysis/src/lib.rs b/crates/analysis/src/lib.rs index abe05f4..6411392 100644 --- a/crates/analysis/src/lib.rs +++ b/crates/analysis/src/lib.rs @@ -199,9 +199,10 @@ pub fn external_placeholder(_: &dyn Inputs, _: &Value) -> Result Result { - let spec = tool(id).ok_or_else(|| format!("no tool '{id}'"))?; +/// A tool's parameters with defaults filled in, or an error naming a missing required one. +/// [`run`] does this first; a host that runs an external tool another way (the browser's +/// asynchronous runner) does it too, so both check parameters the same way. +pub fn resolve_params(spec: &ToolSpec, params: &Value) -> Result { let mut p = params.as_object().cloned().unwrap_or_default(); for ps in spec.params { if !p.contains_key(ps.name) @@ -214,7 +215,13 @@ pub fn run(id: &str, inputs: &dyn Inputs, params: &Value) -> Result Result { + let spec = tool(id).ok_or_else(|| format!("no tool '{id}'"))?; + let p = resolve_params(spec, params)?; // A bug in a tool must not take the app down. std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match PROVIDER.get() { Some((_, runner)) if is_external(spec) => runner(spec, inputs, &p), diff --git a/crates/engine/src/cmd/gp.rs b/crates/engine/src/cmd/gp.rs index 31668c3..fe536e8 100644 --- a/crates/engine/src/cmd/gp.rs +++ b/crates/engine/src/cmd/gp.rs @@ -24,11 +24,13 @@ pub fn commands() -> Vec { C::new("gp.geolibre", "GeoLibre Tools", "Analysis › Geoprocessing", |_, _| Ok(geolibre_status())).view(), C::new("gp.installGeolibre", "Install GeoLibre Tools", "Analysis › Geoprocessing", |_, _| { let path = install_geolibre(&|_, _| {})?; - #[cfg(feature = "geolibre")] + #[cfg(all(feature = "geolibre", not(target_arch = "wasm32")))] rustgis_geolibre::set_runner(Some(std::path::PathBuf::from(&path)))?; Ok(json!({"path": path, "status": geolibre_status()})) }) .view(), + C::new("gp.finish", "Finish Tool Run", "Analysis › Geoprocessing", finish) + .params(r#"{"id": a finished run (the browser's GeoLibre tools run asynchronously)}"#), C::new("gp.setGeolibre", "Locate GeoLibre Runner", "Analysis › Geoprocessing", set_geolibre) .view() .params(r#"{"path"?: "the geolibre executable (empty: look in the usual places)"}"#), @@ -39,13 +41,15 @@ pub fn commands() -> Vec { ] } -/// Whether the GeoLibre tools are available: the runner in use, its version and tool count. +/// Whether the GeoLibre tools are available. Natively: the runner in use, its version and tool +/// count. In the browser (`web`): whether the WASI runner's tools have been loaded. pub fn geolibre_status() -> Value { - #[cfg(feature = "geolibre")] + #[cfg(all(feature = "geolibre", not(target_arch = "wasm32")))] { let found = rustgis_geolibre::runner::find(); json!({ "supported": true, + "web": false, "available": found.is_some(), "path": found.as_ref().map(|(p, _)| p.display().to_string()), "version": found.map(|(_, v)| v), @@ -53,40 +57,142 @@ pub fn geolibre_status() -> Value { "download": rustgis_geolibre::runner::DOWNLOAD_URL, }) } + #[cfg(all(feature = "geolibre", target_arch = "wasm32"))] + { + let n = rustgis_analysis::external_tools().len(); + json!({"supported": true, "web": true, "available": n > 0, "tools": n}) + } #[cfg(not(feature = "geolibre"))] json!({"supported": false, "available": false}) } /// Downloads and installs the latest GeoLibre runner for this platform; returns the program's /// path. `progress` gets the bytes received and the expected total. Blocks: the app calls it -/// from a background thread, then picks the runner with `gp.setGeolibre`. +/// from a background thread, then picks the runner with `gp.setGeolibre`. Native only. pub fn install_geolibre(progress: &dyn Fn(u64, Option)) -> Result { - #[cfg(feature = "geolibre")] + #[cfg(all(feature = "geolibre", not(target_arch = "wasm32")))] { rustgis_geolibre::install::install(progress).map(|p| p.display().to_string()) } - #[cfg(not(feature = "geolibre"))] + #[cfg(not(all(feature = "geolibre", not(target_arch = "wasm32"))))] { let _ = progress; - Err("this build doesn't include the GeoLibre tools".into()) + Err("this build can't install the GeoLibre runner".into()) } } -/// Chooses the GeoLibre runner (an optional download), or looks for it again. +/// Chooses the GeoLibre runner (an optional download), or looks for it again. Native only. fn set_geolibre(_s: &mut Session, p: &Value) -> CmdResult { - #[cfg(feature = "geolibre")] + #[cfg(all(feature = "geolibre", not(target_arch = "wasm32")))] { let path = ps(p, "path").map(str::trim).filter(|p| !p.is_empty()).map(std::path::PathBuf::from); rustgis_geolibre::set_runner(path)?; Ok(geolibre_status()) } - #[cfg(not(feature = "geolibre"))] + #[cfg(not(all(feature = "geolibre", not(target_arch = "wasm32"))))] { let _ = p; + Err("this build has no GeoLibre runner to choose".into()) + } +} + +/// Loads the GeoLibre tool list from manifests (the JSON array the runner lists), as the +/// browser does from its WASI runner; returns how many tools there are. +pub fn external_load_manifests(json: &str) -> Result { + #[cfg(feature = "geolibre")] + { + rustgis_geolibre::set_manifests(json) + } + #[cfg(not(feature = "geolibre"))] + { + let _ = json; + Err("this build doesn't include the GeoLibre tools".into()) + } +} + +/// How many asynchronous runs (started, or finished and not yet applied) the session keeps. +#[cfg(feature = "geolibre")] +const MAX_ASYNC_RUNS: usize = 8; + +/// A GeoLibre run to carry out asynchronously (the browser's WASI runner): run `tool` with +/// `argv` in a working folder `/work` holding `files`, then pass the new files and the printed +/// lines to [`external_complete`] with `id`. +#[derive(Clone, Debug)] +pub struct ExternalRun { + pub id: u64, + pub tool: String, + pub argv: Vec, + pub files: Vec<(String, Vec)>, +} + +/// The new files and printed lines of a finished run, or the runner's error. +pub type ExternalOutput = Result<(Vec<(String, Vec)>, Vec), String>; + +/// Prepares a run of a GeoLibre tool on the active map, checking its parameters as `gp.run` +/// does; the session keeps it until [`external_complete`]. +pub fn external_start(s: &mut Session, tool: &str, params: &Value) -> Result { + #[cfg(feature = "geolibre")] + { + let spec = rustgis_analysis::tool(tool) + .filter(|t| rustgis_analysis::is_external(t)) + .ok_or_else(|| format!("no GeoLibre tool '{tool}'"))?; + let params = rustgis_analysis::resolve_params(spec, params)?; + let job = rustgis_geolibre::prepare(spec, &MapInputs(s.map(), &s.project.tables), ¶ms)?; + s.next_pending = s.next_pending.wrapping_add(1); + let id = s.next_pending; + let run = ExternalRun { id, tool: job.tool.clone(), argv: job.argv("/work"), files: job.files.clone() }; + s.started.push((id, job, params, now_ms())); + // A run whose runner never answers is dropped eventually (it holds its input files). + if s.started.len() > MAX_ASYNC_RUNS { + s.started.remove(0); + } + Ok(run) + } + #[cfg(not(feature = "geolibre"))] + { + let _ = (s, tool, params); + Err("this build doesn't include the GeoLibre tools".into()) + } +} + +/// Finishes a run from [`external_start`]: turns the runner's files and lines into a result +/// that `gp.finish {"id"}` then applies like `gp.run` would, with undo and history. +pub fn external_complete(s: &mut Session, id: u64, ran: ExternalOutput) -> Result<(), String> { + #[cfg(feature = "geolibre")] + { + let i = s + .started + .iter() + .position(|(n, ..)| *n == id) + .ok_or("the tool's run was dropped (its project was closed, or too many runs were waiting)")?; + let (_, job, params, t0) = s.started.remove(i); + // Reading the runner's files back parses outside `Session::run`'s guard: a bug in a + // reader must not take the app down. + let result = ran.map_err(|e| format!("{}: {e}", job.name)).and_then(|(files, lines)| { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| rustgis_geolibre::finish(&job, &files, &lines, None))) + .map_err(|_| format!("{}: its output couldn't be read", job.name)) + }); + s.pending.push(crate::PendingRun { id, tool: job.name.clone(), params, result, millis: now_ms().saturating_sub(t0) }); + if s.pending.len() > MAX_ASYNC_RUNS { + s.pending.remove(0); + } + Ok(()) + } + #[cfg(not(feature = "geolibre"))] + { + let _ = (s, id, ran); Err("this build doesn't include the GeoLibre tools".into()) } } +/// Applies a finished run stashed by [`external_complete`]. +fn finish(s: &mut Session, p: &Value) -> CmdResult { + let id = p.get("id").and_then(Value::as_u64).ok_or("id is required")?; + let i = s.pending.iter().position(|r| r.id == id).ok_or("no finished run with that id")?; + let r = s.pending.remove(i); + apply(s, r.tool, r.params, r.result, r.millis) +} + struct MapInputs<'a>(&'a rustgis_map::Map, &'a [Dataset]); impl Inputs for MapInputs<'_> { @@ -117,6 +223,11 @@ fn run(s: &mut Session, p: &Value) -> CmdResult { let res = rustgis_analysis::run(&tool, &MapInputs(s.map(), &s.project.tables), ¶ms); let millis = now_ms().saturating_sub(t0); let name = rustgis_analysis::tool(&tool).map(|t| t.name).unwrap_or("Tool").to_string(); + apply(s, name, params, res, millis) +} + +/// Adds a tool's result to the map and the history. +fn apply(s: &mut Session, name: String, params: Value, res: Result, millis: u64) -> CmdResult { let mut result = match res { Err(e) => { s.project.gp_history.push(GpRecord { tool: name, params, ok: false, messages: vec![e.clone()], millis }); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 448165d..4dd3d75 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -181,6 +181,22 @@ pub struct Session { last_camera: Option, /// The projection the extent history's cameras are in (their units change with it). history_projection: String, + /// Finished asynchronous tool runs waiting for `gp.finish` (the browser's GeoLibre tools). + pub(crate) pending: Vec, + #[cfg_attr(not(feature = "geolibre"), allow(dead_code))] + pub(crate) next_pending: u64, + /// Asynchronous runs started and not yet complete: (id, job, resolved params, start time). + #[cfg(feature = "geolibre")] + pub(crate) started: Vec<(u64, rustgis_geolibre::Job, serde_json::Value, u64)>, +} + +/// A finished tool run, held until `gp.finish` applies it. +pub(crate) struct PendingRun { + pub id: u64, + pub tool: String, + pub params: serde_json::Value, + pub result: Result, + pub millis: u64, } impl Session { @@ -205,6 +221,10 @@ impl Session { forward: Vec::new(), last_camera: None, history_projection: String::new(), + pending: Vec::new(), + next_pending: 0, + #[cfg(feature = "geolibre")] + started: Vec::new(), } } @@ -284,6 +304,10 @@ impl Session { self.forward.clear(); self.last_camera = None; self.dirty = false; + // Runs of the previous project mustn't land in this one. + self.pending.clear(); + #[cfg(feature = "geolibre")] + self.started.clear(); } pub(crate) fn undo(&mut self) -> bool { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9a5e24d..d9e23b4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -407,3 +407,42 @@ fn geolibre_tools_run_on_layers_and_add_their_outputs() { let err = s.run("gp.run", &json!({"tool": "geolibre:slope", "params": {"input": "Cities"}})).unwrap_err(); assert!(err.starts_with("Slope:"), "{err}"); } + +/// The browser's way: prepare a run, let a runner (here, pretend) produce the files, then +/// apply it with `gp.finish`, which keeps undo and history. +#[cfg(feature = "geolibre")] +#[test] +fn geolibre_runs_can_finish_asynchronously() { + use crate::cmd::gp::{external_complete, external_start}; + let mut s = with_raster(); + assert!(s.run("gp.finish", &json!({"id": 99})).is_err(), "nothing pending"); + assert!(external_complete(&mut s, 99, Ok((vec![], vec![]))).is_err(), "nothing started"); + if s.run("gp.geolibre", &Value::Null).unwrap()["available"] != true { + eprintln!("skipped: no GeoLibre runner (set RUSTGIS_GEOLIBRE)"); + return; + } + assert!(external_start(&mut s, "geolibre:slope", &json!({})).unwrap_err().contains("required")); + assert!(external_start(&mut s, "buffer", &json!({})).is_err(), "only GeoLibre tools"); + let run = external_start(&mut s, "geolibre:slope", &json!({"input": "scene"})).unwrap(); + assert_eq!((run.tool.as_str(), run.files.len()), ("slope", 1), "the embedded raster is staged"); + assert!(run.argv.iter().any(|a| a.starts_with("--output=/work/"))); + // The "runner" writes the input back as the output. + let out = run.files[0].1.clone(); + external_complete(&mut s, run.id, Ok((vec![("output.tif".into(), out)], vec![]))).unwrap(); + let before = s.map().layers.len(); + let r = s.run("gp.finish", &json!({"id": run.id})).unwrap(); + assert_eq!(r["layers"].as_array().map(Vec::len), Some(1), "{r}"); + assert_eq!(s.map().layers.len(), before + 1); + assert!(s.project.gp_history.last().is_some_and(|h| h.ok && h.tool == "Slope")); + s.run("edit.undo", &Value::Null).unwrap(); + assert_eq!(s.map().layers.len(), before, "undo removes it"); + assert!(s.run("gp.finish", &json!({"id": run.id})).is_err(), "applied once"); + // A runner error is reported like a failed gp.run. + let run = external_start(&mut s, "geolibre:slope", &json!({"input": "scene"})).unwrap(); + external_complete(&mut s, run.id, Err("trap".into())).unwrap(); + assert_eq!(s.run("gp.finish", &json!({"id": run.id})).unwrap_err(), "Slope: trap"); + // A run of a project that was closed meanwhile doesn't land in the new one. + let run = external_start(&mut s, "geolibre:slope", &json!({"input": "scene"})).unwrap(); + s.run("project.new", &Value::Null).unwrap(); + assert!(external_complete(&mut s, run.id, Ok((vec![], vec![]))).unwrap_err().contains("project was closed")); +} diff --git a/crates/geolibre/Cargo.toml b/crates/geolibre/Cargo.toml index 986dedb..ec0ad02 100644 --- a/crates/geolibre/Cargo.toml +++ b/crates/geolibre/Cargo.toml @@ -15,7 +15,8 @@ rustgis-analysis = { workspace = true } serde_json = { workspace = true } log = { workspace = true } # One-click install of the runner: download the release archive over HTTPS (pure-Rust TLS), -# check its SHA-256 and unpack it. +# check its SHA-256 and unpack it. Native only: the browser uses the WASI runner instead. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] ureq = { version = "3", default-features = false, features = ["rustls", "gzip"] } sha2 = "0.10" flate2 = "1" diff --git a/crates/geolibre/src/lib.rs b/crates/geolibre/src/lib.rs index 094844c..f9847c6 100644 --- a/crates/geolibre/src/lib.rs +++ b/crates/geolibre/src/lib.rs @@ -19,11 +19,15 @@ //! like any program the user installs, so a tool could write elsewhere if asked to. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(not(target_arch = "wasm32"))] pub mod install; +#[cfg(not(target_arch = "wasm32"))] +mod native; +#[cfg(not(target_arch = "wasm32"))] pub mod runner; use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::Mutex; use rustgis_analysis::{Inputs, Output, OutputLayer, ParamKind, ParamSpec, ToolResult, ToolSpec}; @@ -42,12 +46,29 @@ pub fn register() { /// Uses `exe` as the runner (`None`: look in the usual places), and reloads the tool list. /// Returns the runner in use and its version. -pub fn set_runner(exe: Option) -> Result, String> { +#[cfg(not(target_arch = "wasm32"))] +pub fn set_runner(exe: Option) -> Result, String> { let found = runner::choose(exe)?; rustgis_analysis::reload_external(); Ok(found) } +/// Manifests given directly (the browser's runner lists them asynchronously), used instead of +/// asking the native runner. +static MANIFESTS: Mutex>> = Mutex::new(None); + +/// Uses `manifests` (the JSON array `geolibre manifests` prints) for the tool list, and reloads +/// it. This is how the browser build gets its tools. +pub fn set_manifests(json: &str) -> Result { + let v: Vec = serde_json::from_str(json).map_err(|e| format!("unexpected manifests: {e}"))?; + let n = v.len(); + if let Ok(mut m) = MANIFESTS.lock() { + *m = Some(v); + } + rustgis_analysis::reload_external(); + Ok(n) +} + /// What a parameter is for. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Role { @@ -127,12 +148,25 @@ fn label_of(name: &str) -> String { c.next().map(|f| f.to_uppercase().chain(c).collect()).unwrap_or_default() } -/// Builds every tool's spec from the runner's manifests (none without a runner). +/// The manifests: given directly, else the native runner's (none without one). +fn manifests() -> Vec { + if let Some(m) = MANIFESTS.lock().ok().and_then(|m| m.clone()) { + return m; + } + #[cfg(not(target_arch = "wasm32"))] + { + runner::manifests().unwrap_or_else(|e| { + log::info!("GeoLibre tools: {e}"); + Vec::new() + }) + } + #[cfg(target_arch = "wasm32")] + Vec::new() +} + +/// Builds every tool's spec from the manifests (none without them). fn specs() -> Vec { - let manifests = runner::manifests().unwrap_or_else(|e| { - log::info!("GeoLibre tools: {e}"); - Vec::new() - }); + let manifests = manifests(); let mut meta = HashMap::new(); let mut out: Vec = manifests .iter() @@ -228,50 +262,6 @@ fn spec_of(m: &Value) -> Option<(ToolSpec, Meta)> { Some((spec, Meta { category, params: meta })) } -/// A scratch folder for one run, removed afterwards. -struct Scratch { - dir: PathBuf, - /// Staged inputs so far, so each gets its own file name. - staged: std::cell::Cell, -} - -impl Scratch { - /// A new, empty folder: never one that already exists (left over, or made by someone else - /// on a shared machine), so no old file can pass for a tool's output. - fn new() -> Result { - static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(0); - let mut last = None; - for _ in 0..16 { - let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!("rustgis-gp-{}-{nanos:x}-{n}", std::process::id())); - match std::fs::create_dir(&dir) { - Ok(()) => return Ok(Scratch { dir, staged: std::cell::Cell::new(0) }), - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => last = Some(e), - Err(e) => return Err(format!("can't create a scratch folder: {e}")), - } - } - Err(format!("can't create a scratch folder: {}", last.map(|e| e.to_string()).unwrap_or_default())) - } -} - -impl Scratch { - /// A new file name for a staged input. Numbered, because two layer names can map to the - /// same safe name (`Roads 2020` and `Roads_2020`, or any two non-ASCII names), and one - /// file must never replace another. - fn input(&self, name: &str, ext: &str) -> PathBuf { - let n = self.staged.get(); - self.staged.set(n + 1); - self.dir.join(format!("in{n}-{}.{ext}", safe(name))) - } -} - -impl Drop for Scratch { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.dir); - } -} - /// `g` with every point converted by `f`, or `None` when a point can't be (rather than /// geometry with null coordinates). fn convert(g: &Geometry, f: impl Fn(Coord) -> Option) -> Option { @@ -318,9 +308,9 @@ fn working_crs(rasters: &[Crs], vectors: &[Extent]) -> Crs { Crs::from_epsg(if c.y >= 0.0 { 32600 + zone } else { 32700 + zone }).unwrap_or(Crs::LonLat) } -/// A vector layer (its selection, if any) as GeoJSON in `crs`; returns how many features' -/// geometry couldn't be converted (they're written without geometry). -fn write_vector(ds: &Dataset, rows: &[usize], crs: Crs, tf: &Transformer, path: &Path) -> Result { +/// A vector layer (its selection, if any) as GeoJSON in `crs`, and how many features' geometry +/// couldn't be converted (they're written without geometry). +fn vector_bytes(ds: &Dataset, rows: &[usize], crs: Crs, tf: &Transformer) -> Result<(Vec, usize), String> { let mut out = ds.clone(); if !rows.is_empty() { out.features = rows.iter().filter_map(|&r| ds.features.get(r).cloned()).collect(); @@ -333,58 +323,130 @@ fn write_vector(ds: &Dataset, rows: &[usize], crs: Crs, tf: &Transformer, path: // The legacy GeoJSON 2008 `crs` member, which the suite's reader honours. o.insert("crs".into(), json!({"type": "name", "properties": {"name": format!("urn:ogc:def:crs:EPSG::{}", crs.epsg())}})); } - let text = serde_json::to_string(&json).map_err(|e| e.to_string())?; - std::fs::write(path, text).map_err(|e| format!("can't write {}: {e}", path.display()))?; - Ok(lost) + Ok((serde_json::to_vec(&json).map_err(|e| e.to_string())?, lost)) } -/// Writes one input (a layer by name, or a file path) to the scratch folder; returns its path. -/// Problems that don't stop the run go to `notes`. -fn stage( - inputs: &dyn Inputs, - value: &str, - data: Data, +/// A file a tool argument refers to. +#[derive(Clone, Debug)] +enum FileRef { + /// A staged input or an output, by file name in the run's working folder. + Work(String), + /// A file the user named, used where it is (native only). + Path(String), +} + +/// A tool argument. +#[derive(Clone, Debug)] +enum Arg { + Files(Vec), + Value(Value), +} + +/// A run, prepared: the input files to stage, the arguments, and what to do with the outputs. +/// [`prepare`] makes it from the map's layers; a runner (the native `geolibre` program, or the +/// browser's WASI runner) stages the files in a working folder, runs the tool there, and hands +/// the new files and its printed lines to [`finish`]. +#[derive(Clone, Debug)] +pub struct Job { + /// The runner's tool id (without [`PREFIX`]). + pub tool: String, + /// The tool's display name. + pub name: String, + /// Input files to place in the working folder, by file name. + pub files: Vec<(String, Vec)>, + args: Vec<(String, Arg)>, + /// (file name, layer name) for each declared output. + outputs: Vec<(String, String)>, crs: Crs, - tf: &Transformer, - scratch: &Scratch, - notes: &mut Vec, -) -> Result { + /// Problems found while staging that don't stop the run. + notes: Vec, +} + +impl Job { + /// The arguments, with working-folder files under `dir` (a scratch folder natively, `/work` + /// in the browser), as `(name, value)` pairs for `--name=value`. + pub fn args(&self, dir: &str) -> Vec<(String, Value)> { + let dir = dir.trim_end_matches(['/', '\\']); + let sep = if dir.contains('\\') && !dir.contains('/') { '\\' } else { '/' }; + self.args + .iter() + .map(|(k, a)| { + let v = match a { + Arg::Value(v) => v.clone(), + Arg::Files(files) => Value::String( + files + .iter() + .map(|f| match f { + FileRef::Work(n) => format!("{dir}{sep}{n}"), + FileRef::Path(p) => p.clone(), + }) + .collect::>() + .join(";"), + ), + }; + (k.clone(), v) + }) + .collect() + } + + /// The arguments as `--name=value` strings (see [`Job::args`]). + pub fn argv(&self, dir: &str) -> Vec { + self.args(dir).into_iter().map(|(k, v)| format!("--{k}={}", arg_text(&v))).collect() + } + + fn stage(&mut self, name: &str, ext: &str, bytes: Vec) -> FileRef { + // Numbered, because two layer names can map to the same safe name (`Roads 2020` and + // `Roads_2020`, or any two non-ASCII names), and one file must never replace another. + let file = format!("in{}-{}.{ext}", self.files.len(), safe(name)); + self.files.push((file.clone(), bytes)); + FileRef::Work(file) + } +} + +/// A parameter value as the runner's `--key=value` text. +pub fn arg_text(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +/// Stages one input (a layer by name, or a file path) for `job`. +fn stage(inputs: &dyn Inputs, value: &str, data: Data, tf: &Transformer, job: &mut Job) -> Result { if let Some(r) = inputs.raster(value) { - if let Some(p) = r.path.filter(|p| Path::new(p).is_file()) { - return Ok(p.to_string()); + if let Some(p) = r.path.filter(|p| on_disk(p)) { + return Ok(FileRef::Path(p.to_string())); } if let Some((file_name, bytes)) = r.embedded { // The tools tell formats apart by extension. let ext = Path::new(file_name).extension().and_then(|e| e.to_str()).map(safe).unwrap_or_else(|| "tif".into()); - let path = scratch.input(value, &ext); - std::fs::write(&path, bytes).map_err(|e| format!("can't write {}: {e}", path.display()))?; - return Ok(path.display().to_string()); + return Ok(job.stage(value, &ext, bytes.to_vec())); } let bytes = rustgis_data::raster::geotiff::write(r.raster, 0).map_err(|e| format!("{value}: {e}"))?; - let path = scratch.input(value, "tif"); - std::fs::write(&path, bytes).map_err(|e| format!("can't write {}: {e}", path.display()))?; - return Ok(path.display().to_string()); + return Ok(job.stage(value, "tif", bytes)); } if let Some(ds) = inputs.dataset(value) { if data == Data::Table || (ds.features.iter().all(|f| f.geometry.is_none()) && data != Data::Vector) { - let path = scratch.input(value, "csv"); let text = rustgis_data::io::to_csv(ds).map_err(|e| format!("{value}: {e}"))?; - std::fs::write(&path, text).map_err(|e| format!("can't write {}: {e}", path.display()))?; - return Ok(path.display().to_string()); + return Ok(job.stage(value, "csv", text.into_bytes())); } - let path = scratch.input(value, "geojson"); - let lost = write_vector(ds, inputs.selection(value), crs, tf, &path)?; + let (bytes, lost) = vector_bytes(ds, inputs.selection(value), job.crs, tf)?; if lost > 0 { - notes.push(format!("{value}: {lost} features lie outside the working coordinate system and were passed without geometry")); + job.notes.push(format!("{value}: {lost} features lie outside the working coordinate system and were passed without geometry")); } - return Ok(path.display().to_string()); + return Ok(job.stage(value, "geojson", bytes)); } - if Path::new(value).exists() { - return Ok(value.to_string()); + if on_disk(value) { + return Ok(FileRef::Path(value.to_string())); } Err(format!("there is no layer or file named '{value}'")) } +/// Whether `p` names something on disk (never in the browser, which has no file system). +fn on_disk(p: &str) -> bool { + !cfg!(target_arch = "wasm32") && Path::new(p).exists() +} + /// The extension an output is written with. fn output_ext(p: &MetaParam, meta: &Meta) -> &'static str { let has_raster_input = meta.params.iter().any(|q| q.role == Role::Input && q.data == Data::Raster); @@ -403,96 +465,34 @@ fn output_ext(p: &MetaParam, meta: &Meta) -> &'static str { } } +/// A number as JSON, whole numbers as integers: the runner reads `--n=3.0` as a float, which a +/// tool expecting an integer (a filter size, a count) rejects. +fn number(n: f64) -> Value { + if n.fract() == 0.0 && n.abs() < 9.0e15 { json!(n as i64) } else { json!(n) } +} + /// A form value as the JSON the tool expects (the form sends text). fn option_value(v: &Value, data: Data) -> Value { match (v.as_str().map(str::trim), data) { (Some("true"), Data::Bool) => Value::Bool(true), (Some("false"), Data::Bool) => Value::Bool(false), - (Some(s), Data::Number) => s.parse::().ok().filter(|n| n.is_finite()).map(|n| json!(n)).unwrap_or_else(|| v.clone()), + (Some(s), Data::Number) => s.parse::().ok().filter(|n| n.is_finite()).map(number).unwrap_or_else(|| v.clone()), + (None, Data::Number) => v.as_f64().filter(|n| n.is_finite()).map(number).unwrap_or_else(|| v.clone()), _ => v.clone(), } } -/// Coordinates that can't be longitude/latitude (a tool kept the working coordinate system). -fn looks_projected(ds: &Dataset) -> bool { - let e = ds.extent(); - !e.is_empty() && (e.min_x < -180.5 || e.max_x > 180.5 || e.min_y < -90.5 || e.max_y > 90.5) -} - -/// Reads one output file back as a layer, table or message. -fn load(path: &Path, layer_name: &str, crs: Crs, tf: &Transformer, messages: &mut Vec) -> Option { - let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); - let file_name = path.file_name().and_then(|f| f.to_str()).unwrap_or("output").to_string(); - match ext.as_str() { - "tif" | "tiff" | "asc" => { - let bytes = std::fs::read(path).ok()?; - match rustgis_data::raster::read_bytes(&file_name, &bytes) { - Ok(_) => Some(OutputLayer::Raster { name: layer_name.to_string(), file_name, bytes }), - Err(e) => { - messages.push(format!("{layer_name}: {e}")); - None - } - } - } - "geojson" | "json" | "shp" | "csv" => match rustgis_data::io::read_path(path) { - Ok((mut ds, _)) => { - ds.name = layer_name.to_string(); - if crs != Crs::LonLat && looks_projected(&ds) { - let lost = convert_features(&mut ds, |c| tf.to_lonlat(c)); - if lost > 0 { - messages.push(format!( - "{layer_name}: {lost} features couldn't be converted to longitude/latitude and have no geometry" - )); - } - } - Some(if ds.features.iter().all(|f| f.geometry.is_none()) { OutputLayer::Table(ds) } else { OutputLayer::Vector(ds) }) - } - // A JSON report rather than GeoJSON: show it. - Err(_) if ext == "json" => { - text_message(path, layer_name, messages); - None - } - Err(e) => { - messages.push(format!("{layer_name}: {e}")); - None - } - }, - "txt" | "html" | "htm" => { - text_message(path, layer_name, messages); - None - } - _ => { - messages.push(format!("{layer_name}: wrote a .{ext} file, which RustGIS can't open as a layer")); - None - } - } -} - -/// The start of a text or HTML report as a message. -fn text_message(path: &Path, layer_name: &str, messages: &mut Vec) { - let Ok(text) = std::fs::read_to_string(path) else { return }; - let mut plain = String::new(); - let mut in_tag = false; - for c in text.chars() { - match c { - '<' => in_tag = true, - '>' => in_tag = false, - _ if !in_tag => plain.push(c), - _ => {} - } - } - let lines: Vec<&str> = plain.lines().map(str::trim).filter(|l| !l.is_empty()).take(30).collect(); - if !lines.is_empty() { - messages.push(format!("{layer_name}:\n{}", lines.join("\n"))); - } -} - -fn run(spec: &ToolSpec, inputs: &dyn Inputs, params: &Value) -> Result { +/// Prepares a run of `spec` (a GeoLibre tool) on the map's layers, with `params` already +/// resolved (defaults filled, required ones present: see `rustgis_analysis::resolve_params`). +pub fn prepare(spec: &ToolSpec, inputs: &dyn Inputs, params: &Value) -> Result { let id = spec.id.strip_prefix(PREFIX).ok_or("not a GeoLibre tool")?; let meta = META.lock().ok().and_then(|m| m.as_ref().and_then(|m| m.get(id)).cloned()).ok_or_else(|| format!("{}: unknown tool", spec.name))?; - let meta = &meta; - let scratch = Scratch::new()?; + prepare_with(spec, &meta, inputs, params) +} + +fn prepare_with(spec: &ToolSpec, meta: &Meta, inputs: &dyn Inputs, params: &Value) -> Result { + let id = spec.id.strip_prefix(PREFIX).ok_or("not a GeoLibre tool")?; let value_of = |name: &str| params.get(name).filter(|v| !v.is_null() && v.as_str() != Some("")); let names = |v: &Value| -> Vec { v.as_str().map(|s| s.split(';').map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).collect()).unwrap_or_default() @@ -512,62 +512,163 @@ fn run(spec: &ToolSpec, inputs: &dyn Inputs, params: &Value) -> Result = Vec::new(); - let mut notes = Vec::new(); - let mut outputs = Vec::new(); let n_outputs = meta.params.iter().filter(|p| p.role == Role::Output).count(); for p in &meta.params { match p.role { Role::Input => { let Some(v) = value_of(&p.name) else { continue }; - let paths = - names(v).iter().map(|n| stage(inputs, n, p.data, crs, &tf, &scratch, &mut notes)).collect::, _>>()?; - if !paths.is_empty() { - args.push((p.name.clone(), Value::String(paths.join(";")))); + let files = names(v).iter().map(|n| stage(inputs, n, p.data, &tf, &mut job)).collect::, _>>()?; + if !files.is_empty() { + job.args.push((p.name.clone(), Arg::Files(files))); } } Role::Output => { - let path = scratch.dir.join(format!("{}.{}", safe(&p.name), output_ext(p, meta))); - args.push((p.name.clone(), Value::String(path.display().to_string()))); + let file = format!("{}.{}", safe(&p.name), output_ext(p, meta)); + job.args.push((p.name.clone(), Arg::Files(vec![FileRef::Work(file.clone())]))); let name = if n_outputs > 1 { format!("{} {}", spec.name, label_of(&p.name).to_lowercase()) } else { spec.name.to_string() }; - outputs.push((path, name)); + job.outputs.push((file, name)); } Role::Option => { if let Some(v) = value_of(&p.name) { - args.push((p.name.clone(), option_value(v, p.data))); + job.args.push((p.name.clone(), Arg::Value(option_value(v, p.data)))); + } + } + } + } + Ok(job) +} + +/// Coordinates that can't be longitude/latitude (a tool kept the working coordinate system). +fn looks_projected(ds: &Dataset) -> bool { + let e = ds.extent(); + !e.is_empty() && (e.min_x < -180.5 || e.max_x > 180.5 || e.min_y < -90.5 || e.max_y > 90.5) +} + +/// Reads one output file back as a layer, table or message. `dir` is the native working folder, +/// for formats read with their sidecar files (shapefiles). +fn load(file_name: &str, bytes: &[u8], layer_name: &str, job: &Job, dir: Option<&Path>, messages: &mut Vec) -> Option { + let ext = Path::new(file_name).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); + match ext.as_str() { + "tif" | "tiff" | "asc" => match rustgis_data::raster::read_bytes(file_name, bytes) { + Ok(_) => Some(OutputLayer::Raster { name: layer_name.to_string(), file_name: file_name.to_string(), bytes: bytes.to_vec() }), + Err(e) => { + messages.push(format!("{layer_name}: {e}")); + None + } + }, + "geojson" | "json" | "shp" | "csv" => { + let read = match (ext.as_str(), dir) { + ("shp", Some(d)) => rustgis_data::io::read_path(&d.join(file_name)), + _ => rustgis_data::io::read_bytes(file_name, bytes), + }; + match read { + Ok((mut ds, _)) => { + ds.name = layer_name.to_string(); + if job.crs != Crs::LonLat + && looks_projected(&ds) + && let Ok(tf) = job.crs.transformer() + { + let lost = convert_features(&mut ds, |c| tf.to_lonlat(c)); + if lost > 0 { + messages.push(format!( + "{layer_name}: {lost} features couldn't be converted to longitude/latitude and have no geometry" + )); + } + } + Some(if ds.features.iter().all(|f| f.geometry.is_none()) { OutputLayer::Table(ds) } else { OutputLayer::Vector(ds) }) + } + // A JSON report rather than GeoJSON: show it. + Err(_) if ext == "json" => { + text_message(bytes, layer_name, messages); + None + } + Err(e) => { + messages.push(format!("{layer_name}: {e}")); + None } } } + "txt" | "html" | "htm" => { + text_message(bytes, layer_name, messages); + None + } + _ => { + messages.push(format!("{layer_name}: wrote a .{ext} file, which RustGIS can't open as a layer")); + None + } } +} - let ran = runner::run(id, &args, &scratch.dir).map_err(|e| format!("{}: {e}", spec.name))?; - let mut messages = notes; - messages.extend(ran.messages); +/// The start of a text or HTML report as a message. +fn text_message(bytes: &[u8], layer_name: &str, messages: &mut Vec) { + let text = String::from_utf8_lossy(bytes); + let mut plain = String::new(); + let mut in_tag = false; + for c in text.chars() { + match c { + '<' => in_tag = true, + '>' => in_tag = false, + _ if !in_tag => plain.push(c), + _ => {} + } + } + let lines: Vec<&str> = plain.lines().map(str::trim).filter(|l| !l.is_empty()).take(30).collect(); + if !lines.is_empty() { + messages.push(format!("{layer_name}:\n{}", lines.join("\n"))); + } +} + +/// Turns a finished run into layers and messages. `files` are the new files in the working +/// folder (by file name), `lines` what the runner printed (its last line, if JSON, holds the +/// tool's structured results), `dir` the native working folder if there is one. +pub fn finish(job: &Job, files: &[(String, Vec)], lines: &[String], dir: Option<&Path>) -> ToolResult { + let mut lines: Vec<&str> = lines.iter().map(|l| l.trim()).filter(|l| !l.is_empty()).collect(); + let outputs = match lines.last().map(|l| serde_json::from_str::(l)) { + Some(Ok(Value::Object(m))) => { + lines.pop(); + m + } + _ => serde_json::Map::new(), + }; + let mut messages = job.notes.clone(); + messages.extend(lines.into_iter().take(40).map(str::to_string)); let mut layers = Vec::new(); - for (path, name) in &outputs { + for (file, name) in &job.outputs { // A tool may pick its own extension (a shapefile for a vector output). - let found = if path.is_file() { - Some(path.clone()) - } else { - let stem = path.file_stem().map(|s| s.to_os_string()); - std::fs::read_dir(&scratch.dir).ok().into_iter().flatten().filter_map(|e| e.ok().map(|e| e.path())).find(|f| { - f.file_stem().map(|s| s.to_os_string()) == stem && f.extension().is_some_and(|e| e != "dbf" && e != "shx" && e != "prj") + let stem = Path::new(file).file_stem(); + let found = files.iter().find(|(f, _)| f == file).or_else(|| { + files.iter().find(|(f, _)| { + let p = Path::new(f); + p.file_stem() == stem && p.extension().is_some_and(|e| e != "dbf" && e != "shx" && e != "prj") }) - }; - if let Some(f) = found - && let Some(l) = load(&f, name, crs, &tf, &mut messages) + }); + if let Some((f, bytes)) = found + && let Some(l) = load(f, bytes, name, job, dir, &mut messages) { layers.push(l); } } - // Statistics and other results the tool returns rather than writes. - let scratch_dir = scratch.dir.display().to_string(); - for (k, v) in &ran.outputs { + // Statistics and other results the tool returns rather than writes (paths of the outputs, + // which came back as layers, are left out). + for (k, v) in &outputs { let text = match v { - Value::String(s) if s.starts_with(&scratch_dir) => continue, + Value::String(s) + if job.outputs.iter().any(|(f, _)| s == f || s.ends_with(&format!("/{f}")) || s.ends_with(&format!("\\{f}"))) => + { + continue; + } Value::String(s) => s.clone(), other => other.to_string(), }; @@ -575,9 +676,24 @@ fn run(spec: &ToolSpec, inputs: &dyn Inputs, params: &Value) -> Result Result { + let job = prepare(spec, inputs, params)?; + #[cfg(not(target_arch = "wasm32"))] + { + native::run(&job).map_err(|e| format!("{}: {e}", spec.name)) + } + #[cfg(target_arch = "wasm32")] + { + let _ = job; + Err(format!("{}: in the browser, run GeoLibre tools from the Geoprocessing pane", spec.name)) } - Ok(ToolResult { output: Output::Layers(layers), messages }) } #[cfg(test)] diff --git a/crates/geolibre/src/native.rs b/crates/geolibre/src/native.rs new file mode 100644 index 0000000..1566888 --- /dev/null +++ b/crates/geolibre/src/native.rs @@ -0,0 +1,63 @@ +//! Running a prepared [`Job`] natively: stage its files in a fresh scratch folder, run the +//! `geolibre` program there, and read the new files back. + +use std::path::{Path, PathBuf}; + +use rustgis_analysis::ToolResult; + +use crate::{Job, finish, runner}; + +/// A scratch folder for one run, removed afterwards. +struct Scratch(PathBuf); + +impl Scratch { + /// A new, empty folder: never one that already exists (left over, or made by someone else + /// on a shared machine), so no old file can pass for a tool's output. + fn new() -> Result { + static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(0); + let mut last = None; + for _ in 0..16 { + let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("rustgis-gp-{}-{nanos:x}-{n}", std::process::id())); + match std::fs::create_dir(&dir) { + Ok(()) => return Ok(Scratch(dir)), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => last = Some(e), + Err(e) => return Err(format!("can't create a scratch folder: {e}")), + } + } + Err(format!("can't create a scratch folder: {}", last.map(|e| e.to_string()).unwrap_or_default())) + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// The files in `dir` that aren't staged inputs, by file name. +fn new_files(dir: &Path, job: &Job) -> Vec<(String, Vec)> { + let Ok(rd) = std::fs::read_dir(dir) else { return Vec::new() }; + rd.filter_map(|e| e.ok()) + .filter(|e| e.path().is_file()) + .filter_map(|e| { + let name = e.file_name().to_string_lossy().to_string(); + if job.files.iter().any(|(f, _)| *f == name) { + return None; + } + std::fs::read(e.path()).ok().map(|b| (name, b)) + }) + .collect() +} + +pub(crate) fn run(job: &Job) -> Result { + let scratch = Scratch::new()?; + for (name, bytes) in &job.files { + let path = scratch.0.join(name); + std::fs::write(&path, bytes).map_err(|e| format!("can't write {}: {e}", path.display()))?; + } + let lines = runner::run(&job.tool, &job.argv(&scratch.0.display().to_string()), &scratch.0)?; + let files = new_files(&scratch.0, job); + Ok(finish(job, &files, &lines, Some(&scratch.0))) +} diff --git a/crates/geolibre/src/runner.rs b/crates/geolibre/src/runner.rs index 1389adf..8032f7e 100644 --- a/crates/geolibre/src/runner.rs +++ b/crates/geolibre/src/runner.rs @@ -167,43 +167,18 @@ pub fn manifests() -> Result, String> { } } -/// A parameter value as the runner's `--key=value` text. -pub fn arg_text(v: &Value) -> String { - match v { - Value::String(s) => s.clone(), - other => other.to_string(), - } -} - -/// What a run printed: its messages, and the structured outputs (the last line, JSON). -pub struct Ran { - pub messages: Vec, - pub outputs: serde_json::Map, -} - -/// Runs one tool in `dir`. The error carries what the tool wrote to stderr. -pub fn run(id: &str, args: &[(String, Value)], dir: &Path) -> Result { +/// Runs one tool in `dir` with `argv` (`--name=value` strings); returns the lines it printed. +/// The error carries what the tool wrote to stderr. +pub fn run(id: &str, argv: &[String], dir: &Path) -> Result, String> { let (exe, _) = find().ok_or("the GeoLibre runner isn't installed")?; let mut c = command(&exe); - c.current_dir(dir).stdin(Stdio::null()).arg(id); - for (k, v) in args { - c.arg(format!("--{k}={}", arg_text(v))); - } + c.current_dir(dir).stdin(Stdio::null()).arg(id).args(argv); let out = c.output().map_err(|e| format!("{}: {e}", exe.display()))?; - let stdout = String::from_utf8_lossy(&out.stdout); if !out.status.success() { let err = String::from_utf8_lossy(&out.stderr); let err = err.trim(); let err = if err.is_empty() { "the tool stopped unexpectedly" } else { err }; return Err(err.lines().take(20).collect::>().join("\n")); } - let mut lines: Vec<&str> = stdout.lines().map(str::trim).filter(|l| !l.is_empty()).collect(); - let outputs = match lines.last().map(|l| serde_json::from_str::(l)) { - Some(Ok(Value::Object(m))) => { - lines.pop(); - m - } - _ => serde_json::Map::new(), - }; - Ok(Ran { messages: lines.into_iter().take(40).map(str::to_string).collect(), outputs }) + Ok(String::from_utf8_lossy(&out.stdout).lines().map(str::to_string).collect()) } diff --git a/crates/geolibre/src/tests.rs b/crates/geolibre/src/tests.rs index 7251e7e..9f8302f 100644 --- a/crates/geolibre/src/tests.rs +++ b/crates/geolibre/src/tests.rs @@ -54,27 +54,19 @@ fn setup() -> Option { #[test] fn runner_values_and_choices() { - assert_eq!(runner::arg_text(&json!("a b")), "a b"); - assert_eq!(runner::arg_text(&json!(true)), "true"); - assert_eq!(runner::arg_text(&json!(2.5)), "2.5"); + assert_eq!(arg_text(&json!("a b")), "a b"); + assert_eq!(arg_text(&json!(true)), "true"); + assert_eq!(arg_text(&json!(2.5)), "2.5"); // Not a runner: refused, and the choice stays as it was. let before = runner::find(); let me = std::env::current_exe().unwrap(); assert!(runner::version_of(&me).is_none()); assert!(set_runner(Some(me)).is_err()); - assert!(set_runner(Some(PathBuf::from("/no/such/geolibre"))).is_err()); + assert!(set_runner(Some(std::path::PathBuf::from("/no/such/geolibre"))).is_err()); assert_eq!(runner::find(), before); assert_eq!(label_of("z_factor"), "Z factor"); // Reloading the tools reuses the strings it made rather than leaking new copies. assert!(std::ptr::eq(leak("Slope"), leak(String::from("Slope")))); - // Two layer names with the same safe name still get two files. - let scratch = Scratch::new().unwrap(); - let (a, b) = (scratch.input("Roads 2020", "geojson"), scratch.input("Roads_2020", "geojson")); - assert_ne!(a, b); - assert_ne!(scratch.input("城市", "tif"), scratch.input("道路", "tif")); - let dir = scratch.dir.clone(); - drop(scratch); - assert!(!dir.exists(), "removed after the run"); // A point that can't be converted drops the geometry instead of writing nulls. let line = Geometry::Line(vec![vec![Coord::new(0.0, 0.0), Coord::new(1.0, 1.0)]]); assert!(convert(&line, |c| Some(Coord::new(c.x * 2.0, c.y))).is_some()); @@ -163,3 +155,63 @@ fn bad_inputs_are_errors_not_crashes() { assert!(rustgis_analysis::run(t.id, &f, &Value::Object(junk)).is_err(), "{}", t.id); } } + +/// The browser's path, with no runner: prepare a run, then finish it from files a runner +/// would have written. +#[test] +fn prepare_and_finish_without_a_runner() { + let f = Fixture::new(); + let manifest = json!({ + "id": "test_buffer", "display_name": "Test Buffer", "category": "Vector", "summary": "", + "params": [ + {"name": "input", "io_role": "input", "data_kind": "vector", "required": true, "schema": {"kind": "input", "cardinality": "single"}}, + {"name": "others", "io_role": "input", "data_kind": "raster", "required": false, "schema": {"kind": "input", "cardinality": "multiple"}}, + {"name": "distance", "data_kind": "number", "required": true, "schema": {"kind": "scalar"}}, + {"name": "output", "io_role": "output", "data_kind": "vector", "required": false, "schema": {"kind": "output"}} + ] + }); + let (spec, meta) = spec_of(&manifest).unwrap(); + let params = rustgis_analysis::resolve_params(&spec, &json!({"input": "pts", "others": "dem;dem", "distance": "250"})).unwrap(); + let job = prepare_with(&spec, &meta, &f, ¶ms).unwrap(); + // Inputs are staged as numbered files, even the same layer twice. + let names: Vec<&str> = job.files.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!(names, ["in0-pts.geojson", "in1-dem.tif", "in2-dem.tif"]); + // The working CRS is the raster's (UTM 17N): the GeoJSON says so. + assert!(String::from_utf8_lossy(&job.files[0].1).contains("EPSG::32617")); + assert_eq!( + job.argv("/work"), + [ + "--input=/work/in0-pts.geojson", + "--others=/work/in1-dem.tif;/work/in2-dem.tif", + "--distance=250", + "--output=/work/output.geojson" + ] + ); + // Whole numbers go as integers (the form sends numbers as floats). + assert_eq!(option_value(&json!(3.0), Data::Number), json!(3)); + assert_eq!(option_value(&json!("2.5"), Data::Number), json!(2.5)); + assert!(job.argv("C:\\scratch").iter().any(|a| a == "--output=C:\\scratch\\output.geojson"), "Windows folders"); + + // Finish from what a runner returns: the output in UTM, and a JSON results line. + let utm = Crs::from_epsg(32617).unwrap().transformer().unwrap(); + let c = utm.from_lonlat(f.points.extent().center()).unwrap(); + let out = format!( + r#"{{"type":"FeatureCollection","features":[{{"type":"Feature","properties":{{"id":1}},"geometry":{{"type":"Point","coordinates":[{},{}]}}}}]}}"#, + c.x, c.y + ); + let lines = vec!["working…".to_string(), r#"{"output":"/work/output.geojson","log":"/work/my_output.geojson","count":1}"#.to_string()]; + let r = finish(&job, &[("output.geojson".into(), out.into_bytes())], &lines, None); + let Output::Layers(layers) = r.output else { panic!("layers") }; + let Some(OutputLayer::Vector(ds)) = layers.first() else { panic!("a vector: {layers:?}") }; + assert_eq!(ds.name, "Test Buffer"); + let p = ds.extent().center(); + let want = f.points.extent().center(); + assert!((p.x - want.x).abs() < 1e-6 && (p.y - want.y).abs() < 1e-6, "back in lon/lat: {p:?}"); + assert!(r.messages.contains(&"working…".to_string())); + assert!(r.messages.iter().any(|m| m == "Count: 1"), "results, without the output path: {:?}", r.messages); + assert!(!r.messages.iter().any(|m| m.contains("/work/output.geojson")), "the output came back as a layer"); + assert!(r.messages.iter().any(|m| m.contains("my_output.geojson")), "another file isn't mistaken for it"); + // No files back: a message, not a crash. + let empty = finish(&job, &[], &[], None); + assert!(matches!(empty.output, Output::Layers(ref l) if l.is_empty()) && !empty.messages.is_empty()); +} diff --git a/crates/ui-egui/src/i18n/az.tsv b/crates/ui-egui/src/i18n/az.tsv index 4677c8a..d3fd73a 100644 --- a/crates/ui-egui/src/i18n/az.tsv +++ b/crates/ui-egui/src/i18n/az.tsv @@ -259,6 +259,8 @@ Find a place by coordinates. Koordinatlara görə yer tapın. Find and run geoprocessing tools. Geoemal alətlərini tapın və işə salın. Find any command by name (Ctrl+K). İstənilən əmri adına görə tapın (Ctrl+K). Find Tools Alət axtar +Finish Tool Run Alətin işini tamamla +Finished: {tool} Tamamlandı: {tool} Fixed Zoom In Sabit yaxınlaşdırma Fixed Zoom Out Sabit uzaqlaşdırma Folders Qovluqlar @@ -342,6 +344,8 @@ Line width Xətt qalınlığı LineString LineString List Commands Əmrlərin siyahısı Lit İşıqlı +Load GeoLibre tools GeoLibre alətlərini yüklə +Loading the GeoLibre tools… GeoLibre alətləri yüklənir… Locate Yer tap Locate GeoLibre Runner GeoLibre işlədicisini tap Locate the GeoLibre runner GeoLibre işlədicisini tap @@ -434,6 +438,7 @@ Outline color Kontur rəngi Outline width Kontur qalınlığı Output Çıxış Output Name Çıxış adı +Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. Təxminən 25 MB-lıq birdəfəlik endirmə ilə brauzerdə işləyən 1000-dən çox raster, vektor, LiDAR, hidrologiya və relyef aləti. Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. Təxminən 20 MB-lıq istəyə bağlı endirmə ilə 1000-dən çox raster, vektor, LiDAR, hidrologiya və relyef aləti. Overlay Üst-üstə qoyma Pan (Explore) · any tool Sürüşdür (Kəşf) · istənilən alət @@ -499,6 +504,7 @@ Ribbon parity: {done} of {total} ribbon features have a RustGIS command. Lent uy Robinson Robinson Run İşə sal Run Tool Aləti işə sal +Running… İşləyir… RustGIS RustGIS RustGIS is an open-source desktop GIS written in pure Rust. Its ribbon follows the familiar desktop GIS workflow; every action is a command you can also run from the command line. RustGIS təmiz Rust dilində yazılmış açıq mənbəli masaüstü CİS-dir. Onun lenti tanış masaüstü CİS iş axınını izləyir; hər əməliyyat komanda sətrindən də işə sala biləcəyiniz bir əmrdir. RustGIS project RustGIS layihəsi diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 05035b0..65e6e1a 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -259,6 +259,8 @@ Find a place by coordinates. Einen Ort über Koordinaten finden. Find and run geoprocessing tools. Geoverarbeitungswerkzeuge finden und ausführen. Find any command by name (Ctrl+K). Einen beliebigen Befehl nach Namen finden (Ctrl+K). Find Tools Werkzeuge finden +Finish Tool Run Werkzeuglauf abschließen +Finished: {tool} Fertig: {tool} Fixed Zoom In Fester Zoom hinein Fixed Zoom Out Fester Zoom heraus Folders Ordner @@ -342,6 +344,8 @@ Line width Linienbreite LineString LineString List Commands Befehle auflisten Lit Beleuchtet +Load GeoLibre tools GeoLibre-Werkzeuge laden +Loading the GeoLibre tools… GeoLibre-Werkzeuge werden geladen… Locate Lokalisieren Locate GeoLibre Runner GeoLibre-Runner suchen Locate the GeoLibre runner GeoLibre-Runner suchen @@ -434,6 +438,7 @@ Outline color Umrissfarbe Outline width Umrissbreite Output Ausgabe Output Name Ausgabename +Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. Über 1.000 Raster-, Vektor-, LiDAR-, Hydrologie- und Geländewerkzeuge, die im Browser laufen, nach einem einmaligen Download von etwa 25 MB. Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. Über 1.000 Raster-, Vektor-, LiDAR-, Hydrologie- und Geländewerkzeuge als optionaler Download von etwa 20 MB. Overlay Überlagerung Pan (Explore) · any tool Verschieben (Erkunden) · jedes Werkzeug @@ -499,6 +504,7 @@ Ribbon parity: {done} of {total} ribbon features have a RustGIS command. Menüba Robinson Robinson Run Ausführen Run Tool Werkzeug ausführen +Running… Läuft… RustGIS RustGIS RustGIS is an open-source desktop GIS written in pure Rust. Its ribbon follows the familiar desktop GIS workflow; every action is a command you can also run from the command line. RustGIS ist ein Open-Source-Desktop-GIS in reinem Rust. Sein Menüband folgt dem vertrauten Desktop-GIS-Workflow; jede Aktion ist ein Befehl, den Sie auch über die Kommandozeile ausführen können. RustGIS project RustGIS-Projekt diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index aee7e54..de1a425 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -259,6 +259,8 @@ Find a place by coordinates. Buscar un lugar por coordenadas. Find and run geoprocessing tools. Buscar y ejecutar herramientas de geoprocesamiento. Find any command by name (Ctrl+K). Buscar cualquier comando por su nombre (Ctrl+K). Find Tools Buscar herramientas +Finish Tool Run Finalizar ejecución de herramienta +Finished: {tool} Terminado: {tool} Fixed Zoom In Acercar fijo Fixed Zoom Out Alejar fijo Folders Carpetas @@ -342,6 +344,8 @@ Line width Ancho de línea LineString LineString List Commands Listar comandos Lit Iluminado +Load GeoLibre tools Cargar herramientas de GeoLibre +Loading the GeoLibre tools… Cargando las herramientas de GeoLibre… Locate Localizar Locate GeoLibre Runner Localizar el ejecutor de GeoLibre Locate the GeoLibre runner Localizar el ejecutor de GeoLibre @@ -434,6 +438,7 @@ Outline color Color del contorno Outline width Ancho del contorno Output Salida Output Name Nombre de salida +Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. Más de 1.000 herramientas ráster, vectoriales, LiDAR, hidrológicas y de terreno que se ejecutan en el navegador, tras una única descarga de unos 25 MB. Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. Más de 1.000 herramientas ráster, vectoriales, LiDAR, hidrológicas y de terreno, en una descarga opcional de unos 20 MB. Overlay Superposición Pan (Explore) · any tool Desplazar (Explorar) · cualquier herramienta @@ -499,6 +504,7 @@ Ribbon parity: {done} of {total} ribbon features have a RustGIS command. Paridad Robinson Robinson Run Ejecutar Run Tool Ejecutar herramienta +Running… Ejecutando… RustGIS RustGIS RustGIS is an open-source desktop GIS written in pure Rust. Its ribbon follows the familiar desktop GIS workflow; every action is a command you can also run from the command line. RustGIS es un SIG de escritorio de código abierto escrito en Rust puro. Su cinta sigue el flujo de trabajo habitual de los SIG de escritorio; cada acción es un comando que también puede ejecutar desde la línea de comandos. RustGIS project Proyecto de RustGIS diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index a1e8cf1..83a303c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -259,6 +259,8 @@ Find a place by coordinates. Trouver un lieu par ses coordonnées. Find and run geoprocessing tools. Trouver et exécuter des outils de géotraitement. Find any command by name (Ctrl+K). Trouver une commande par son nom (Ctrl+K). Find Tools Rechercher des outils +Finish Tool Run Terminer l'exécution de l'outil +Finished: {tool} Terminé : {tool} Fixed Zoom In Zoom avant fixe Fixed Zoom Out Zoom arrière fixe Folders Dossiers @@ -342,6 +344,8 @@ Line width Épaisseur de ligne LineString LineString List Commands Lister les commandes Lit Éclairé +Load GeoLibre tools Charger les outils GeoLibre +Loading the GeoLibre tools… Chargement des outils GeoLibre… Locate Localiser Locate GeoLibre Runner Localiser l'exécuteur GeoLibre Locate the GeoLibre runner Localiser l'exécuteur GeoLibre @@ -434,6 +438,7 @@ Outline color Couleur du contour Outline width Épaisseur du contour Output Sortie Output Name Nom en sortie +Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. Plus de 1 000 outils raster, vecteur, LiDAR, hydrologie et terrain qui s'exécutent dans le navigateur, après un téléchargement unique d'environ 25 Mo. Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. Plus de 1 000 outils raster, vecteur, LiDAR, hydrologie et terrain, en téléchargement facultatif d'environ 20 Mo. Overlay Superposition Pan (Explore) · any tool Déplacer (Explorer) · tout outil @@ -499,6 +504,7 @@ Ribbon parity: {done} of {total} ribbon features have a RustGIS command. Parité Robinson Robinson Run Exécuter Run Tool Exécuter l'outil +Running… Exécution… RustGIS RustGIS RustGIS is an open-source desktop GIS written in pure Rust. Its ribbon follows the familiar desktop GIS workflow; every action is a command you can also run from the command line. RustGIS est un SIG bureautique open source écrit en Rust pur. Son ruban suit le flux de travail familier des SIG bureautiques ; chaque action est une commande que vous pouvez aussi lancer en ligne de commande. RustGIS project Projet RustGIS diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index bdd0605..d386c8a 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -259,6 +259,8 @@ Find a place by coordinates. Temukan tempat berdasarkan koordinat. Find and run geoprocessing tools. Temukan dan jalankan alat geoprosesing. Find any command by name (Ctrl+K). Temukan perintah apa pun berdasarkan nama (Ctrl+K). Find Tools Cari Alat +Finish Tool Run Selesaikan proses alat +Finished: {tool} Selesai: {tool} Fixed Zoom In Perbesar Tetap Fixed Zoom Out Perkecil Tetap Folders Folder @@ -342,6 +344,8 @@ Line width Lebar garis LineString LineString List Commands Daftar Perintah Lit Terang +Load GeoLibre tools Muat alat GeoLibre +Loading the GeoLibre tools… Memuat alat GeoLibre… Locate Temukan Lokasi Locate GeoLibre Runner Temukan runner GeoLibre Locate the GeoLibre runner Temukan runner GeoLibre @@ -434,6 +438,7 @@ Outline color Warna garis tepi Outline width Lebar garis tepi Output Keluaran Output Name Nama Keluaran +Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. Lebih dari 1.000 alat raster, vektor, LiDAR, hidrologi, dan medan yang berjalan di peramban, dengan unduhan sekali sekitar 25 MB. Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. Lebih dari 1.000 alat raster, vektor, LiDAR, hidrologi, dan medan, dari unduhan opsional sekitar 20 MB. Overlay Overlay Pan (Explore) · any tool Geser (Jelajah) · alat apa pun @@ -499,6 +504,7 @@ Ribbon parity: {done} of {total} ribbon features have a RustGIS command. Paritas Robinson Robinson Run Jalankan Run Tool Jalankan Alat +Running… Berjalan… RustGIS RustGIS RustGIS is an open-source desktop GIS written in pure Rust. Its ribbon follows the familiar desktop GIS workflow; every action is a command you can also run from the command line. RustGIS adalah GIS desktop sumber terbuka yang ditulis dengan Rust murni. Ribbon-nya mengikuti alur kerja GIS desktop yang familiar; setiap aksi adalah perintah yang juga dapat dijalankan dari baris perintah. RustGIS project Proyek RustGIS diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index eda8da6..595d888 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -259,6 +259,8 @@ Find a place by coordinates. Trova un luogo tramite coordinate. Find and run geoprocessing tools. Trova ed esegui strumenti di geoprocessing. Find any command by name (Ctrl+K). Trova qualsiasi comando per nome (Ctrl+K). Find Tools Trova strumenti +Finish Tool Run Completa l'esecuzione dello strumento +Finished: {tool} Completato: {tool} Fixed Zoom In Ingrandimento fisso Fixed Zoom Out Riduzione fissa Folders Cartelle @@ -342,6 +344,8 @@ Line width Spessore linea LineString LineString List Commands Elenca comandi Lit Illuminato +Load GeoLibre tools Carica gli strumenti GeoLibre +Loading the GeoLibre tools… Caricamento degli strumenti GeoLibre… Locate Localizza Locate GeoLibre Runner Individua l'esecutore GeoLibre Locate the GeoLibre runner Individua l'esecutore GeoLibre @@ -434,6 +438,7 @@ Outline color Colore contorno Outline width Spessore contorno Output Output Output Name Nome output +Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. Oltre 1.000 strumenti raster, vettoriali, LiDAR, idrologici e del terreno che funzionano nel browser, con un unico download di circa 25 MB. Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. Oltre 1.000 strumenti raster, vettoriali, LiDAR, idrologici e del terreno, in un download facoltativo di circa 20 MB. Overlay Sovrapposizione Pan (Explore) · any tool Pan (Esplora) · qualsiasi strumento @@ -499,6 +504,7 @@ Ribbon parity: {done} of {total} ribbon features have a RustGIS command. Parità Robinson Robinson Run Esegui Run Tool Esegui strumento +Running… In esecuzione… RustGIS RustGIS RustGIS is an open-source desktop GIS written in pure Rust. Its ribbon follows the familiar desktop GIS workflow; every action is a command you can also run from the command line. RustGIS è un GIS desktop open source scritto in puro Rust. La sua barra multifunzione segue il consueto flusso di lavoro dei GIS desktop; ogni azione è un comando che puoi eseguire anche dalla riga di comando. RustGIS project Progetto RustGIS diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index eb94dde..7edf8ad 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -259,6 +259,8 @@ Find a place by coordinates. 座標で場所を検索します。 Find and run geoprocessing tools. ジオプロセシング ツールを検索して実行します。 Find any command by name (Ctrl+K). 名前で任意のコマンドを検索します(Ctrl+K)。 Find Tools ツールの検索 +Finish Tool Run ツールの実行を完了 +Finished: {tool} 完了: {tool} Fixed Zoom In 固定ズームイン Fixed Zoom Out 固定ズームアウト Folders フォルダー @@ -342,6 +344,8 @@ Line width ラインの幅 LineString ライン ストリング List Commands コマンド一覧 Lit 明部 +Load GeoLibre tools GeoLibre ツールを読み込む +Loading the GeoLibre tools… GeoLibre ツールを読み込んでいます… Locate 位置 Locate GeoLibre Runner GeoLibre ランナーの場所を指定 Locate the GeoLibre runner GeoLibre ランナーの場所を指定 @@ -434,6 +438,7 @@ Outline color アウトラインの色 Outline width アウトラインの幅 Output 出力 Output Name 出力名 +Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. ブラウザーで動く、ラスター、ベクター、LiDAR、水文、地形の 1,000 以上のツール (初回のみ約 25 MB をダウンロード)。 Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. ラスター、ベクター、LiDAR、水文、地形の 1,000 以上のツール (約 20 MB のオプションのダウンロード)。 Overlay オーバーレイ Pan (Explore) · any tool 画面移動(マップ操作)· 任意のツール @@ -499,6 +504,7 @@ Ribbon parity: {done} of {total} ribbon features have a RustGIS command. リボ Robinson ロビンソン図法 Run 実行 Run Tool ツールの実行 +Running… 実行中… RustGIS RustGIS RustGIS is an open-source desktop GIS written in pure Rust. Its ribbon follows the familiar desktop GIS workflow; every action is a command you can also run from the command line. RustGIS は純粋な Rust で書かれたオープンソースのデスクトップ GIS です。リボンはおなじみのデスクトップ GIS のワークフローに従っており、すべての操作はコマンドとしてコマンド ラインからも実行できます。 RustGIS project RustGIS プロジェクト diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 7360f4d..01148f8 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -259,6 +259,8 @@ Find a place by coordinates. 좌표로 장소를 찾습니다. Find and run geoprocessing tools. 지오프로세싱 도구를 찾아 실행합니다. Find any command by name (Ctrl+K). 이름으로 명령을 찾습니다(Ctrl+K). Find Tools 도구 찾기 +Finish Tool Run 도구 실행 완료 +Finished: {tool} 완료: {tool} Fixed Zoom In 고정 확대 Fixed Zoom Out 고정 축소 Folders 폴더 @@ -342,6 +344,8 @@ Line width 선 두께 LineString LineString List Commands 명령 목록 Lit 밝은 면 +Load GeoLibre tools GeoLibre 도구 불러오기 +Loading the GeoLibre tools… GeoLibre 도구를 불러오는 중… Locate 위치 찾기 Locate GeoLibre Runner GeoLibre 실행기 찾기 Locate the GeoLibre runner GeoLibre 실행기 찾기 @@ -434,6 +438,7 @@ Outline color 외곽선 색상 Outline width 외곽선 두께 Output 출력 Output Name 출력 이름 +Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. 브라우저에서 실행되는 래스터, 벡터, LiDAR, 수문, 지형 도구 1,000개 이상 (최초 1회 약 25MB 다운로드). Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. 래스터, 벡터, LiDAR, 수문, 지형 도구 1,000개 이상 (약 20MB의 선택적 다운로드). Overlay 중첩 Pan (Explore) · any tool 이동(탐색) · 모든 도구 @@ -499,6 +504,7 @@ Ribbon parity: {done} of {total} ribbon features have a RustGIS command. 리본 Robinson 로빈슨 도법 Run 실행 Run Tool 도구 실행 +Running… 실행 중… RustGIS RustGIS RustGIS is an open-source desktop GIS written in pure Rust. Its ribbon follows the familiar desktop GIS workflow; every action is a command you can also run from the command line. RustGIS는 순수 Rust로 작성된 오픈 소스 데스크톱 GIS입니다. 리본은 익숙한 데스크톱 GIS 워크플로를 따르며, 모든 작업은 명령줄에서도 실행할 수 있는 명령입니다. RustGIS project RustGIS 프로젝트 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 9c54aed..2d70910 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -259,6 +259,8 @@ Find a place by coordinates. Een plaats zoeken op coördinaten. Find and run geoprocessing tools. Geoverwerkingsgereedschappen zoeken en uitvoeren. Find any command by name (Ctrl+K). Elke opdracht op naam zoeken (Ctrl+K). Find Tools Gereedschappen zoeken +Finish Tool Run Gereedschapsrun afronden +Finished: {tool} Klaar: {tool} Fixed Zoom In Vast inzoomen Fixed Zoom Out Vast uitzoomen Folders Mappen @@ -342,6 +344,8 @@ Line width Lijndikte LineString LineString List Commands Opdrachten weergeven Lit Belicht +Load GeoLibre tools GeoLibre-gereedschappen laden +Loading the GeoLibre tools… GeoLibre-gereedschappen laden… Locate Lokaliseren Locate GeoLibre Runner GeoLibre-runner zoeken Locate the GeoLibre runner GeoLibre-runner zoeken @@ -434,6 +438,7 @@ Outline color Omtrekkleur Outline width Omtrekdikte Output Uitvoer Output Name Uitvoernaam +Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. Meer dan 1.000 raster-, vector-, LiDAR-, hydrologie- en terreingereedschappen die in de browser draaien, na een eenmalige download van ongeveer 25 MB. Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. Meer dan 1.000 raster-, vector-, LiDAR-, hydrologie- en terreingereedschappen als optionele download van ongeveer 20 MB. Overlay Overlay Pan (Explore) · any tool Pannen (Verkennen) · elk gereedschap @@ -499,6 +504,7 @@ Ribbon parity: {done} of {total} ribbon features have a RustGIS command. Lintpar Robinson Robinson Run Uitvoeren Run Tool Gereedschap uitvoeren +Running… Bezig… RustGIS RustGIS RustGIS is an open-source desktop GIS written in pure Rust. Its ribbon follows the familiar desktop GIS workflow; every action is a command you can also run from the command line. RustGIS is een opensource desktop-GIS geschreven in puur Rust. Het lint volgt de vertrouwde werkwijze van desktop-GIS; elke actie is een opdracht die je ook vanaf de opdrachtregel kunt uitvoeren. RustGIS project RustGIS-project diff --git a/crates/ui-egui/src/i18n/pt.tsv b/crates/ui-egui/src/i18n/pt.tsv index d433288..b8e8652 100644 --- a/crates/ui-egui/src/i18n/pt.tsv +++ b/crates/ui-egui/src/i18n/pt.tsv @@ -259,6 +259,8 @@ Find a place by coordinates. Encontrar um local por coordenadas. Find and run geoprocessing tools. Encontrar e executar ferramentas de geoprocessamento. Find any command by name (Ctrl+K). Encontrar qualquer comando pelo nome (Ctrl+K). Find Tools Encontrar ferramentas +Finish Tool Run Concluir execução da ferramenta +Finished: {tool} Concluído: {tool} Fixed Zoom In Aproximação fixa Fixed Zoom Out Afastamento fixo Folders Pastas @@ -342,6 +344,8 @@ Line width Largura da linha LineString LineString List Commands Listar comandos Lit Iluminado +Load GeoLibre tools Carregar ferramentas GeoLibre +Loading the GeoLibre tools… Carregando as ferramentas GeoLibre… Locate Localizar Locate GeoLibre Runner Localizar o executor do GeoLibre Locate the GeoLibre runner Localizar o executor do GeoLibre @@ -434,6 +438,7 @@ Outline color Cor do contorno Outline width Largura do contorno Output Saída Output Name Nome de saída +Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. Mais de 1.000 ferramentas raster, vetoriais, LiDAR, hidrológicas e de terreno que rodam no navegador, após um download único de cerca de 25 MB. Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. Mais de 1.000 ferramentas raster, vetoriais, LiDAR, hidrológicas e de terreno, em um download opcional de cerca de 20 MB. Overlay Sobreposição Pan (Explore) · any tool Deslocar (Explorar) · qualquer ferramenta @@ -499,6 +504,7 @@ Ribbon parity: {done} of {total} ribbon features have a RustGIS command. Paridad Robinson Robinson Run Executar Run Tool Executar ferramenta +Running… Executando… RustGIS RustGIS RustGIS is an open-source desktop GIS written in pure Rust. Its ribbon follows the familiar desktop GIS workflow; every action is a command you can also run from the command line. O RustGIS é um SIG desktop de código aberto escrito em Rust puro. Sua faixa de opções segue o fluxo de trabalho familiar dos SIG desktop; cada ação é um comando que você também pode executar pela linha de comando. RustGIS project Projeto do RustGIS diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 6006a94..735d3f6 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -259,6 +259,8 @@ Find a place by coordinates. Найти место по координатам. Find and run geoprocessing tools. Найти и запустить инструменты геообработки. Find any command by name (Ctrl+K). Найти любую команду по названию (Ctrl+K). Find Tools Найти инструменты +Finish Tool Run Завершить запуск инструмента +Finished: {tool} Готово: {tool} Fixed Zoom In Фиксированное приближение Fixed Zoom Out Фиксированное отдаление Folders Папки @@ -342,6 +344,8 @@ Line width Толщина линии LineString LineString List Commands Список команд Lit Освещено +Load GeoLibre tools Загрузить инструменты GeoLibre +Loading the GeoLibre tools… Загрузка инструментов GeoLibre… Locate Найти место Locate GeoLibre Runner Указать модуль запуска GeoLibre Locate the GeoLibre runner Указать модуль запуска GeoLibre @@ -434,6 +438,7 @@ Outline color Цвет контура Outline width Толщина контура Output Вывод Output Name Имя результата +Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. Более 1000 инструментов для растров, векторов, LiDAR, гидрологии и рельефа, работающих в браузере, после однократной загрузки около 25 МБ. Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. Более 1000 инструментов для растров, векторов, LiDAR, гидрологии и рельефа в необязательной загрузке размером около 20 МБ. Overlay Наложение Pan (Explore) · any tool Панорамирование (обзор) · любой инструмент @@ -499,6 +504,7 @@ Ribbon parity: {done} of {total} ribbon features have a RustGIS command. Соо Robinson Робинсон Run Запустить Run Tool Запустить инструмент +Running… Выполняется… RustGIS RustGIS RustGIS is an open-source desktop GIS written in pure Rust. Its ribbon follows the familiar desktop GIS workflow; every action is a command you can also run from the command line. RustGIS — настольная ГИС с открытым исходным кодом, написанная на чистом Rust. Её лента повторяет привычный порядок работы настольной ГИС; каждое действие — это команда, которую также можно выполнить из командной строки. RustGIS project Проект RustGIS diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 58fcec6..4f03c51 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -259,6 +259,8 @@ Find a place by coordinates. Koordinatlarla bir yer bul. Find and run geoprocessing tools. Coğrafi işleme araçlarını bul ve çalıştır. Find any command by name (Ctrl+K). Herhangi bir komutu adıyla bul (Ctrl+K). Find Tools Araç Bul +Finish Tool Run Araç çalıştırmasını tamamla +Finished: {tool} Tamamlandı: {tool} Fixed Zoom In Sabit Yakınlaştır Fixed Zoom Out Sabit Uzaklaştır Folders Klasörler @@ -342,6 +344,8 @@ Line width Çizgi kalınlığı LineString LineString List Commands Komutları Listele Lit Aydınlık +Load GeoLibre tools GeoLibre araçlarını yükle +Loading the GeoLibre tools… GeoLibre araçları yükleniyor… Locate Konum Bul Locate GeoLibre Runner GeoLibre çalıştırıcısını bul Locate the GeoLibre runner GeoLibre çalıştırıcısını bul @@ -434,6 +438,7 @@ Outline color Dış çizgi rengi Outline width Dış çizgi kalınlığı Output Çıktı Output Name Çıktı Adı +Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. Yaklaşık 25 MB'lık tek seferlik bir indirmeyle tarayıcıda çalışan 1.000'den fazla raster, vektör, LiDAR, hidroloji ve arazi aracı. Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. Yaklaşık 20 MB'lık isteğe bağlı bir indirmeyle 1.000'den fazla raster, vektör, LiDAR, hidroloji ve arazi aracı. Overlay Bindirme Pan (Explore) · any tool Kaydır (Keşfet) · herhangi bir araç @@ -499,6 +504,7 @@ Ribbon parity: {done} of {total} ribbon features have a RustGIS command. Şerit Robinson Robinson Run Çalıştır Run Tool Aracı Çalıştır +Running… Çalışıyor… RustGIS RustGIS RustGIS is an open-source desktop GIS written in pure Rust. Its ribbon follows the familiar desktop GIS workflow; every action is a command you can also run from the command line. RustGIS, saf Rust ile yazılmış açık kaynaklı bir masaüstü CBS'dir. Şeridi alışılmış masaüstü CBS iş akışını izler; her eylem, komut satırından da çalıştırabileceğiniz bir komuttur. RustGIS project RustGIS projesi diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 59c7f5e..8bfcf90 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -259,6 +259,8 @@ Find a place by coordinates. Tìm một địa điểm theo tọa độ. Find and run geoprocessing tools. Tìm và chạy công cụ xử lý không gian. Find any command by name (Ctrl+K). Tìm bất kỳ lệnh nào theo tên (Ctrl+K). Find Tools Tìm công cụ +Finish Tool Run Hoàn tất chạy công cụ +Finished: {tool} Đã xong: {tool} Fixed Zoom In Phóng to cố định Fixed Zoom Out Thu nhỏ cố định Folders Thư mục @@ -342,6 +344,8 @@ Line width Độ rộng đường LineString LineString List Commands Liệt kê lệnh Lit Sáng +Load GeoLibre tools Tải công cụ GeoLibre +Loading the GeoLibre tools… Đang tải công cụ GeoLibre… Locate Định vị Locate GeoLibre Runner Tìm trình chạy GeoLibre Locate the GeoLibre runner Tìm trình chạy GeoLibre @@ -434,6 +438,7 @@ Outline color Màu viền Outline width Độ rộng viền Output Đầu ra Output Name Tên đầu ra +Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. Hơn 1.000 công cụ raster, vector, LiDAR, thủy văn và địa hình chạy trong trình duyệt, chỉ cần tải một lần khoảng 25 MB. Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. Hơn 1.000 công cụ raster, vector, LiDAR, thủy văn và địa hình, qua một bản tải tùy chọn khoảng 20 MB. Overlay Chồng lớp Pan (Explore) · any tool Di chuyển (Khám phá) · mọi công cụ @@ -499,6 +504,7 @@ Ribbon parity: {done} of {total} ribbon features have a RustGIS command. Tương Robinson Robinson Run Chạy Run Tool Chạy công cụ +Running… Đang chạy… RustGIS RustGIS RustGIS is an open-source desktop GIS written in pure Rust. Its ribbon follows the familiar desktop GIS workflow; every action is a command you can also run from the command line. RustGIS là GIS máy tính để bàn mã nguồn mở viết hoàn toàn bằng Rust. Ribbon của nó theo quy trình GIS máy tính để bàn quen thuộc; mọi thao tác đều là một lệnh mà bạn cũng có thể chạy từ dòng lệnh. RustGIS project Dự án RustGIS diff --git a/crates/ui-egui/src/i18n/zh.tsv b/crates/ui-egui/src/i18n/zh.tsv index 6bfe744..006dbb5 100644 --- a/crates/ui-egui/src/i18n/zh.tsv +++ b/crates/ui-egui/src/i18n/zh.tsv @@ -259,6 +259,8 @@ Find a place by coordinates. 按坐标查找位置。 Find and run geoprocessing tools. 查找并运行地理处理工具。 Find any command by name (Ctrl+K). 按名称查找任意命令(Ctrl+K)。 Find Tools 查找工具 +Finish Tool Run 完成工具运行 +Finished: {tool} 已完成:{tool} Fixed Zoom In 固定比例放大 Fixed Zoom Out 固定比例缩小 Folders 文件夹 @@ -342,6 +344,8 @@ Line width 线宽 LineString 线串 List Commands 列出命令 Lit 亮面 +Load GeoLibre tools 加载 GeoLibre 工具 +Loading the GeoLibre tools… 正在加载 GeoLibre 工具… Locate 定位 Locate GeoLibre Runner 定位 GeoLibre 运行器 Locate the GeoLibre runner 定位 GeoLibre 运行器 @@ -434,6 +438,7 @@ Outline color 轮廓颜色 Outline width 轮廓宽度 Output 输出 Output Name 输出名称 +Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. 1000 多个在浏览器中运行的栅格、矢量、LiDAR、水文和地形工具,只需一次约 25 MB 的下载。 Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. 1000 多个栅格、矢量、LiDAR、水文和地形工具,约 20 MB 的可选下载。 Overlay 叠加 Pan (Explore) · any tool 平移(浏览)· 任意工具 @@ -499,6 +504,7 @@ Ribbon parity: {done} of {total} ribbon features have a RustGIS command. 功能 Robinson 罗宾森投影 Run 运行 Run Tool 运行工具 +Running… 正在运行… RustGIS RustGIS RustGIS is an open-source desktop GIS written in pure Rust. Its ribbon follows the familiar desktop GIS workflow; every action is a command you can also run from the command line. RustGIS 是一款用纯 Rust 编写的开源桌面 GIS。其功能区遵循熟悉的桌面 GIS 工作流;每个操作都是一条命令,也可以从命令行运行。 RustGIS project RustGIS 工程 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 84bb2c5..f796c79 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -27,6 +27,9 @@ pub use rustgis_engine; /// Files read asynchronously (browser uploads, drops) waiting to be opened: (file name, bytes). pub type Inbox = std::sync::Arc)>>>; +/// A result an asynchronous service fills in when it's done (the app polls it each frame). +pub type Slot = std::sync::Arc>>; + /// Platform hooks. The desktop app leaves them empty and uses native file dialogs; the web app /// supplies an asynchronous file picker (results arrive in `inbox`) and browser downloads. #[derive(Default)] @@ -43,6 +46,11 @@ pub struct Services { /// Downloads a font by URL (web); the bytes land in `font_inbox`. pub fetch_font: Option>, pub font_inbox: Option, + /// The browser's GeoLibre runner: lists the tools' manifests (a JSON array) into the slot. + pub geolibre_manifests: Option>)>>, + /// …and runs one tool in its in-memory `/work` folder, putting the new files and printed + /// lines in the slot. The desktop app runs tools through the `geolibre` program instead. + pub geolibre_run: Option)>>, } /// Panes on the left of the map. @@ -163,6 +171,11 @@ impl RustGisApp { if let Some(m) = self.services.load_pref.as_ref().and_then(|load| load("ribbon_minimized")) { self.ui.ribbon_minimized = m == "true"; } + // The browser's GeoLibre tools, loaded in an earlier visit: load them again (the runner + // comes from the browser's cache). + if self.services.load_pref.as_ref().and_then(|load| load("geolibre_web")).as_deref() == Some("on") { + panes::start_geolibre_load(&mut self); + } // A GeoLibre runner located earlier (Locate… in the Geoprocessing pane). if let Some(path) = self.services.load_pref.as_ref().and_then(|load| load("geolibre_runner")).filter(|p| !p.is_empty()) && let Err(e) = self.session.run("gp.setGeolibre", &json!({"path": path})) @@ -485,6 +498,7 @@ impl RustGisApp { /// Per-frame work that doesn't draw (theme, fonts, files that arrived). pub fn logic(&mut self, ctx: &egui::Context) { i18n::set_current(self.lang()); + panes::poll_geolibre(self, ctx); self.ensure_script_font(); // Fonts downloaded by the web app. let fonts: Vec<(String, Vec)> = match &self.services.font_inbox { diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 6f9fb5b..0b11440 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -523,6 +523,62 @@ pub struct GpForm { pub last: Option<(bool, Vec)>, /// A GeoLibre runner install in progress (it runs on a background thread). pub install: Option>>, + /// The browser's GeoLibre tools loading. + pub web_load: Option>>, + /// A tool running in the browser's GeoLibre runner: its run id, and where the result lands. + pub web_run: Option<(u64, &'static str, crate::Slot)>, +} + +/// Starts loading the browser's GeoLibre tools (the runner downloads once, then the browser +/// caches it). +pub fn start_geolibre_load(app: &mut RustGisApp) { + if app.gp.web_load.is_some() { + return; + } + let Some(load) = &app.services.geolibre_manifests else { return }; + let slot = crate::Slot::default(); + load(slot.clone()); + app.gp.web_load = Some(slot); +} + +fn take(slot: &crate::Slot) -> Option { + slot.lock().ok().and_then(|mut s| s.take()) +} + +/// Picks up what the browser's GeoLibre runner finished: the tool list, or a tool run. +pub fn poll_geolibre(app: &mut RustGisApp, ctx: &egui::Context) { + if let Some(r) = app.gp.web_load.as_ref().and_then(take) { + app.gp.web_load = None; + match r.and_then(|json| rustgis_engine::cmd::gp::external_load_manifests(&json)) { + Ok(_) => { + if let Some(save) = &app.services.save_pref { + save("geolibre_web", "on"); + } + } + Err(e) => app.gp.last = Some((false, vec![e])), + } + } + if let Some((id, tool, out)) = app.gp.web_run.as_ref().and_then(|(id, tool, slot)| take(slot).map(|o| (*id, *tool, o))) { + app.gp.web_run = None; + let r = rustgis_engine::cmd::gp::external_complete(&mut app.session, id, out).and_then(|_| app.run("gp.finish", json!({"id": id}))); + let last = match r { + Ok(_) => (true, app.session.project.gp_history.last().map(|h| h.messages.clone()).unwrap_or_default()), + Err(e) => (false, vec![e]), + }; + // Under its own form only; with another tool open, a notification says how it went. + if app.gp.tool == Some(tool) { + app.gp.last = Some(last); + } else if !last.0 { + app.session.notify(last.1.join("\n"), true); + } else { + let name = rustgis_engine::analysis::tool(tool).map(|t| t.name).unwrap_or(tool); + app.session.notify(crate::i18n::fmt("Finished: {tool}", &[("tool", tl!(name))]), false); + } + app.map.invalidate(); + } + if app.gp.web_load.is_some() || app.gp.web_run.is_some() { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } } /// Progress of a one-click GeoLibre runner install. @@ -591,6 +647,10 @@ fn geolibre_card(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } return; } + if status["web"] == true { + geolibre_web_card(app, ui, t); + return; + } let download = status["download"].as_str().unwrap_or("https://github.com/opengeos/geolibre-rust/releases/latest").to_string(); ui.add_space(6.0); egui::Frame::NONE.stroke(Stroke::new(1.0, t.border)).corner_radius(CornerRadius::same(6)).inner_margin(egui::Margin::same(10)).show( @@ -618,14 +678,7 @@ fn geolibre_card(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } None => { #[cfg(not(target_arch = "wasm32"))] - if ui - .add( - egui::Button::new(egui::RichText::new(tl!("Install GeoLibre tools")).color(egui::Color32::WHITE)) - .fill(t.green) - .corner_radius(CornerRadius::same(6)), - ) - .clicked() - { + if crate::widgets::primary_button(ui, tl!("Install GeoLibre tools"), true, vec2(0.0, 28.0)).clicked() { let st = std::sync::Arc::new(std::sync::Mutex::new(Install::default())); app.gp.install = Some(st.clone()); app.gp.last = None; @@ -674,6 +727,41 @@ fn geolibre_card(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { ); } +/// The browser's version of the card: load the WASI runner's tools. +fn geolibre_web_card(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { + ui.add_space(6.0); + egui::Frame::NONE + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(CornerRadius::same(6)) + .inner_margin(egui::Margin::same(10)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.label(egui::RichText::new(tl!("GeoLibre tools")).font(semibold(13.0))); + ui.label( + egui::RichText::new(tl!( + "Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB." + )) + .size(11.5) + .color(t.text_dim), + ); + ui.add_space(6.0); + if app.gp.web_load.is_some() { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(egui::RichText::new(tl!("Loading the GeoLibre tools…")).size(11.5)); + }); + } else if crate::widgets::primary_button(ui, tl!("Load GeoLibre tools"), true, vec2(0.0, 28.0)).clicked() { + app.gp.last = None; + start_geolibre_load(app); + } + if let Some((false, msgs)) = &app.gp.last { + for m in msgs { + ui.label(egui::RichText::new(m).size(11.0).color(t.red)); + } + } + }); +} + fn geoprocessing(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { let Some(spec) = app.gp.tool.and_then(rustgis_engine::analysis::tool) else { // Tool finder. @@ -752,19 +840,42 @@ fn geoprocessing(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { if q.is_empty() { geolibre_section(app, ui); } - let mut boxes: Vec<&str> = rustgis_engine::analysis::TOOLS.iter().map(|t| t.toolbox).collect(); - boxes.sort(); - boxes.dedup(); - for b in boxes { - let found: Vec<_> = rustgis_engine::analysis::TOOLS.iter().filter(|x| x.toolbox == b && matches(x)).collect(); - if found.is_empty() { - continue; - } - ui.label(egui::RichText::new(crate::i18n::location(b)).size(11.0).color(t.text_dim)); - for tool in found { - row(app, ui, tool); + let builtin = rustgis_engine::analysis::TOOLS; + if q.is_empty() { + // Browsing: one collapsible toolbox per top-level group, like the GeoLibre ones + // (open: there are only a few built-in tools). + ui.label(egui::RichText::new(tl!("RustGIS")).size(11.0).color(t.text_dim)); + let mut tops: Vec<&str> = builtin.iter().map(|t| t.toolbox.split(" › ").next().unwrap_or(t.toolbox)).collect(); + tops.sort(); + tops.dedup(); + for top in tops { + let in_top: Vec<_> = builtin.iter().filter(|x| x.toolbox.split(" › ").next() == Some(top)).collect(); + egui::CollapsingHeader::new(format!("{} ({})", crate::i18n::location(top), in_top.len())) + .id_salt(("gp-builtin", top)) + .default_open(true) + .show(ui, |ui| { + for tool in in_top { + row(app, ui, tool); + } + }); } ui.add_space(6.0); + } else { + // Searching: the matches under their toolbox. + let mut boxes: Vec<&str> = builtin.iter().map(|t| t.toolbox).collect(); + boxes.sort(); + boxes.dedup(); + for b in boxes { + let found: Vec<_> = builtin.iter().filter(|x| x.toolbox == b && matches(x)).collect(); + if found.is_empty() { + continue; + } + ui.label(egui::RichText::new(crate::i18n::location(b)).size(11.0).color(t.text_dim)); + for tool in found { + row(app, ui, tool); + } + ui.add_space(6.0); + } } if !q.is_empty() { geolibre_section(app, ui); @@ -773,7 +884,7 @@ fn geoprocessing(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { }; // Tool form. ui.horizontal(|ui| { - if ui.button(tl!("←")).on_hover_text(tl!("Back to tools")).clicked() { + if crate::widgets::icon_button(ui, "back", tl!("Back to tools")).clicked() { app.gp.tool = None; } let (r, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); @@ -884,11 +995,8 @@ fn geoprocessing(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } ui.add_space(8.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { - let run = egui::Button::new(egui::RichText::new(tl!("Run")).font(semibold(13.0)).color(egui::Color32::WHITE)) - .fill(t.green) - .min_size(vec2(96.0, 30.0)) - .corner_radius(CornerRadius::same(6)); - if ui.add(run).clicked() { + // One browser run at a time. + if crate::widgets::primary_button(ui, tl!("Run"), app.gp.web_run.is_none(), vec2(96.0, 30.0)).clicked() { let mut params = serde_json::Map::new(); for p in spec.params { if let Some(v) = app.gp.values.get(p.name).filter(|v| !v.trim().is_empty()) { @@ -900,14 +1008,37 @@ fn geoprocessing(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { params.insert(p.name.to_string(), val); } } - let r = app.run("gp.run", json!({"tool": spec.id, "params": Value::Object(params)})); - app.gp.last = Some(match r { - Ok(_) => (true, app.session.project.gp_history.last().map(|h| h.messages.clone()).unwrap_or_default()), - Err(e) => (false, vec![e]), - }); - app.map.invalidate(); + let params = Value::Object(params); + if rustgis_engine::analysis::is_external(spec) && app.services.geolibre_run.is_some() { + // The browser's runner is asynchronous: start it, and `poll_geolibre` finishes. + match rustgis_engine::cmd::gp::external_start(&mut app.session, spec.id, ¶ms) { + Ok(run) => { + let (id, slot) = (run.id, crate::Slot::default()); + if let Some(start) = &app.services.geolibre_run { + start(run, slot.clone()); + } + app.gp.web_run = Some((id, spec.id, slot)); + app.gp.last = None; + } + Err(e) => app.gp.last = Some((false, vec![e])), + } + } else { + let r = app.run("gp.run", json!({"tool": spec.id, "params": params})); + app.gp.last = Some(match r { + Ok(_) => (true, app.session.project.gp_history.last().map(|h| h.messages.clone()).unwrap_or_default()), + Err(e) => (false, vec![e]), + }); + app.map.invalidate(); + } } }); + if app.gp.web_run.is_some() { + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.spinner(); + ui.label(egui::RichText::new(tl!("Running…")).size(12.0)); + }); + } if let Some((ok, msgs)) = &app.gp.last { ui.add_space(8.0); let (fill, stroke, head) = diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 4329a72..d527ec0 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -257,3 +257,41 @@ pub fn ribbon_combo(ui: &mut Ui, id: &str, label: &str, current: &str, options: }); out } + +/// A filled action button (Run, Install…). Painted here so the label sits in the middle of the +/// button: egui places button text by the font's line metrics, which leaves some fonts low. +pub fn primary_button(ui: &mut Ui, label: &str, enabled: bool, min_size: egui::Vec2) -> Response { + let t = Tokens::get(ui.ctx()); + let font = semibold(13.0); + let size = vec2(min_size.x.max(text_w(ui, label, font.clone()) + 28.0), min_size.y.max(28.0)); + let (r, resp) = ui.allocate_exact_size(size, if enabled { Sense::click() } else { Sense::hover() }); + let fill = if !enabled { + t.green.gamma_multiply(0.45) + } else if resp.is_pointer_button_down_on() { + t.green.gamma_multiply(0.8) + } else if resp.hovered() { + t.green.gamma_multiply(0.9) + } else { + t.green + }; + ui.painter().rect_filled(r, CornerRadius::same(6), fill); + // Centre on the glyphs' height, not the line's. + let galley = ui.painter().layout_no_wrap(label.to_string(), font, egui::Color32::WHITE); + let rows = galley.rows.first().map(|row| row.rect()).unwrap_or(galley.rect); + let pos = r.center() - vec2(galley.size().x / 2.0, rows.center().y); + ui.painter().galley(pos, galley, egui::Color32::WHITE); + if enabled { resp.on_hover_cursor(egui::CursorIcon::PointingHand) } else { resp } +} + +/// A small icon button (back, close…), drawn with the app's own icons rather than a glyph the +/// interface font may lack. +pub fn icon_button(ui: &mut Ui, icon: &str, hover: &str) -> Response { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); + } + ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + icons::paint(ui.painter(), r.shrink(5.0), icon, t.text, t.accent); + resp.on_hover_text(hover) +} diff --git a/docs/user-guide.md b/docs/user-guide.md index 70fe88b..7b62e85 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -167,11 +167,18 @@ toolboxes, and Find Tools searches them. `gp.tools` lists every tool's parameters, `gp.geolibre` shows the runner in use and `gp.setGeolibre={"path":"…"}` picks one. -The web app doesn't include them: RustGIS turns this integration on only in the desktop app -and the command line. - -RustGIS waits while a tool runs, so a long run on a large raster or point cloud keeps the -window busy until it finishes. +In the web app, click **Load GeoLibre tools** in the Geoprocessing pane instead. RustGIS +loads geolibre-rust's WebAssembly runner (the `geolibre-wasm` package, from jsDelivr, about +25 MB the first time, then from the browser's cache) and the tools run in the page, in an +in-memory folder: nothing is uploaded. The web app remembers this and loads them again on your +next visit. The runner's code comes from jsDelivr, pinned to one geolibre-wasm release, the +same trust as installing that package. Tools that read files by path (LiDAR files, for instance) need the desktop app, +since a web page has no file system. + +In the desktop app and on the command line, RustGIS waits while a tool runs, so a long run on +a large raster or point cloud keeps the window busy until it finishes. In the web app the run +is started in the background and the result is added when it ends, but the tool itself runs +in the page, so the page is busy while it computes. ## Keyboard shortcuts