diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 3cec8c5..602fa48 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -175,6 +175,7 @@ is committed and listed here with its exports.
| packaging/env.sh, packaging/.shellcheckrc, packaging/macos/package.sh, packaging/macos/Info.plist.in, packaging/linux/package.sh, packaging/linux/AppRun, packaging/windows/package.ps1 | SolidCraft `packaging/` and VectorCraft `packaging/` (signing and notarization in macos/package.sh) | MIT OR Apache-2.0 |
| packaging/macos/import-cert.sh | VectorCraft `packaging/macos/import-cert.sh` | MIT OR Apache-2.0 |
| packaging/windows/check-exe.ps1 | SolidCraft `packaging/windows/check-icon.ps1` | MIT OR Apache-2.0 |
+| apps/fieldcraft-web/ (the web runner, browser file services and downloads, Trunk configuration, index.html) | VectorCraft `apps/vectorcraft-web/` and SolidCraft `apps/solidcraft-web/` | MIT OR Apache-2.0 |
| crates/io/src/memory.rs (files kept in memory for the browser build) | SolidCraft `crates/io/src/vfs.rs` | MIT OR Apache-2.0 |
| .github/workflows/release.yml, .github/release-notes.md, docs/releasing.md | SolidCraft `.github/workflows/release.yml`, `.github/release-notes.md`, `docs/releasing.md`; VectorCraft `.github/workflows/release.yml` | MIT OR Apache-2.0 |
| .github/workflows/packaging-lint.yml | SolidCraft `.github/workflows/packaging-lint.yml` (itself adapted from PhotoCraft, https://github.com/storytold/photocraft, Apache-2.0) | MIT OR Apache-2.0 |
diff --git a/Cargo.lock b/Cargo.lock
index 3734871..17a98fa 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -425,6 +425,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
[[package]]
name = "bit-set"
version = "0.10.0"
@@ -1434,6 +1440,25 @@ dependencies = [
"serde_json",
]
+[[package]]
+name = "fieldcraft-web"
+version = "0.5.5"
+dependencies = [
+ "base64",
+ "eframe",
+ "egui",
+ "fieldcraft-engine",
+ "fieldcraft-io",
+ "fieldcraft-ui-egui",
+ "js-sys",
+ "log",
+ "rfd",
+ "serde_json",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
diff --git a/ROADMAP.md b/ROADMAP.md
index 0279177..5a6dc90 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -52,7 +52,7 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
| M23 Wave propagation and ultrasound | Elastic, acoustic and coupled waves from the explicit solver's 1D bar today to guided and ultrasonic waves, by two methods. Finite differences first, for fast 2D (then 3D) ultrasound on regular grids: staggered-grid velocity–stress FDTD (Virieux) and the elastodynamic finite integration technique for solids, the rotated staggered grid for anisotropic media, a k-space pseudospectral scheme for weakly heterogeneous tissue and water, all on grids voxelised from the face-tagged geometry by the LBM's exact voxeliser, with heterogeneous, layered and fluid–solid media, CPML boundaries, threads and the GPU backend; staircased curved boundaries scatter spuriously, so the FD grid is checked against the finite elements on curved parts. Finite elements for curved geometry and coupling with structures: spectral elements (high-order Gauss–Lobatto–Legendre hexes with diagonal mass) on the hex meshes, and a mesh check in elements per wavelength with the dispersion error it implies; absorbing boundaries (convolutional PML, absorbing layers with increasing damping) and infinite media; tone-burst sources (Hann-windowed cycles), point, line and surface excitation, and piezoelectric transducers (M19); anisotropic media (composites, crystals) and viscoelastic, power-law-in-frequency attenuation; fluid–solid interfaces and fluid-loaded plates; ultrasound in water and tissue, nonlinear acoustics (Westervelt, KZK) for focused ultrasound. Guided waves: dispersion curves by semi-analytical finite elements (SAFE) for plates, pipes, rails and layered and anisotropic laminates — Lamb, Rayleigh, Love and SH modes — with phase and group velocities and mode shapes; mode-selective excitation. Output: A-scans and B-scans at sensors, wavefield snapshots, 2D FFT (frequency–wavenumber) for mode identification, and time–frequency diagrams of any signal — short-time Fourier spectrograms, continuous wavelet transforms (Morlet), reassigned spectrograms and smoothed pseudo Wigner–Ville — with the SAFE or Rayleigh–Lamb group-velocity curves overlaid as arrival times, and group velocities extracted from the ridges. GPU paths (M12's backend) for long runs. Verified by Lamb's problem (point load on a half-space), Rayleigh-wave speed, Rayleigh–Lamb dispersion, Love waves in a layer over a half-space, reflection and transmission at interfaces, and published guided-wave measurements | planned |
| Continuous: verification and validation | Every capability checked against theory (closed forms), experiments (published measurements) and published benchmarks (NAFEMS, vendor benchmark and verification manuals such as Abaqus's), each case our own model in `tests/oracle/` with its cited reference and tolerance; `cargo xtask oracle` runs them and writes docs/oracle.md; once a case matches, our result is frozen as a fixture so fidelity cannot regress (docs/verification.md). Runner, fixtures and CI job: done; 26 theory cases (patch tests of every solid element, cantilevers, Lamé in 3D, plane strain and axisymmetric, Kirsch in 3D and plane stress, rotating disc and cylinder, beam frequencies, couplings, explicit waves, impact and J2 plasticity, CDT invariants), 9 published benchmarks (MacNeal–Harder solid tests, Scordelis–Lo with solids, NAFEMS LE1 in 3D and 2D, LE10, FV32) and 9 pending cases with citations (shells, thermal, static plasticity, Taylor bar, CFD); `mesh.block` mapped blocks and 2D patches build their meshes | started |
| Continuous: performance | CPU first, deterministic: results never depend on the thread count (fixed partitions, sums in a fixed order; checked by hashing results on 1, 2 and 7 threads). Supernodal left-looking Cholesky (11–15× faster than the column solver on one thread), parallel element matrices, stresses and Lanczos products, explicit element updates and LBM cell blocks on rayon threads (done — `fieldcraft-parallel`, `threads` on the solve commands, `FIELDCRAFT_THREADS`); parallel forward/back substitution and explicit tet4 assembly; GPU paths later, within a stated tolerance of the CPU | started |
-| Continuous: desktop and agents | Desktop app (viewport, model tree, commands, control channel, the open design drawn, `ui.capture` of the whole window offscreen: done; toolbar of every capability from the catalogue, ready or coming soon with its milestone, icons drawn with VectorCraft, docs/features.md: done; the Craft-family shell — title bar, workspaces and ribbon drop-downs, browser, context panel, command search, journal timeline, light and dark themes — native file pickers with background opening, drag and drop and recent files, samples and one-click Solve: done; MCP server over stdio, headless or bridged to the app, with read and write roots, docs/mcp.md: done; caps labels in Inter SemiBold; the README's analysis video recorded through MCP by `tools/demo-video`: done; support, load, reference-point and coupling symbols in the viewport and headless renders, with their browser toggles: done; selection by picking in the viewport — geometry faces, edges, vertices and bodies, mesh nodes, element faces and elements, box selection, hover, a Target row in command forms, `ui.select`/`ui.selection` and MCP `select`: done; a camera parameter for screenshots, a ribbon that follows the command being run: in progress), browser build, command schema as the public API | started |
+| Continuous: desktop and agents | Desktop app (viewport, model tree, commands, control channel, the open design drawn, `ui.capture` of the whole window offscreen: done; toolbar of every capability from the catalogue, ready or coming soon with its milestone, icons drawn with VectorCraft, docs/features.md: done; the Craft-family shell — title bar, workspaces and ribbon drop-downs, browser, context panel, command search, journal timeline, light and dark themes — native file pickers with background opening, drag and drop and recent files, samples and one-click Solve: done; MCP server over stdio, headless or bridged to the app, with read and write roots, docs/mcp.md: done; caps labels in Inter SemiBold; the README's analysis video recorded through MCP by `tools/demo-video`: done; support, load, reference-point and coupling symbols in the viewport and headless renders, with their browser toggles: done; selection by picking in the viewport — geometry faces, edges, vertices and bodies, mesh nodes, element faces and elements, box selection, hover, a Target row in command forms, `ui.select`/`ui.selection` and MCP `select`: done; a camera parameter for screenshots, a ribbon that follows the command being run: in progress; the browser build — the same app in a web page with browser file pickers, downloads, drops and autosave to browser storage, deployed to GitHub Pages from each release, docs/web.md: done), command schema as the public API | started |
## Next
diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt
index 2eaeedc..acec61b 100644
--- a/THIRD-PARTY-LICENSES.txt
+++ b/THIRD-PARTY-LICENSES.txt
@@ -1,6 +1,6 @@
Third-party software in FieldCraft
-FieldCraft (the desktop app, fieldcraft-cli and the web app) is built from the 451 crates below.
+FieldCraft (the desktop app, fieldcraft-cli and the web app) is built from the 452 crates below.
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.
FieldCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
@@ -41,6 +41,7 @@ atomic-waker 1.1.2 (Apache-2.0 OR MIT)
atspi 0.29.0 (Apache-2.0 OR MIT)
atspi-common 0.13.0 (Apache-2.0 OR MIT)
atspi-proxies 0.13.0 (Apache-2.0 OR MIT)
+base64 0.22.1 (MIT OR Apache-2.0)
bit-set 0.10.0 (Apache-2.0 OR MIT)
bit-vec 0.9.1 (Apache-2.0 OR MIT)
bitflags 1.3.2 (MIT/Apache-2.0)
@@ -753,14 +754,14 @@ DEALINGS IN THE SOFTWARE.
================================================================================
ahash 0.8.12, arbitrary 1.5.0, arrayvec 0.7.8, async-channel 2.5.0, async-executor 1.14.0, async-io
2.6.0, async-lock 3.4.2, async-process 2.5.0, async-recursion 1.2.0, async-signal 0.2.14, async-task
-4.7.1, atomic-waker 1.1.2, bitflags 1.3.2, bitflags 2.13.2, blocking 1.7.0, bumpalo 3.20.3, cfg-if
-1.0.5, cgl 0.3.2, concurrent-queue 2.5.0, core-foundation 0.9.4, core-foundation-sys 0.8.7,
-core-graphics 0.23.2, core-graphics-types 0.1.3, crossbeam-deque 0.8.8, crossbeam-epoch 0.9.21,
-crossbeam-utils 0.8.23, data-url 0.3.2, derive_arbitrary 1.5.0, either 1.19.0, equivalent 1.0.2,
-errno 0.3.14, euclid 0.22.14, event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand 2.5.0,
-flate2 1.1.10, futures-lite 2.6.1, gethostname 1.1.0, glow 0.17.0, hashbrown 0.16.1, hashbrown
-0.17.1, hermit-abi 0.5.3, indexmap 2.14.2, itertools 0.13.0, itertools 0.15.0, js-sys 0.3.106,
-khronos-egl 6.0.0, linux-raw-sys 0.12.1, linux-raw-sys 0.4.15, lock_api 0.4.14, log 0.4.34,
+4.7.1, atomic-waker 1.1.2, base64 0.22.1, bitflags 1.3.2, bitflags 2.13.2, blocking 1.7.0, bumpalo
+3.20.3, cfg-if 1.0.5, cgl 0.3.2, concurrent-queue 2.5.0, core-foundation 0.9.4, core-foundation-sys
+0.8.7, core-graphics 0.23.2, core-graphics-types 0.1.3, crossbeam-deque 0.8.8, crossbeam-epoch
+0.9.21, crossbeam-utils 0.8.23, data-url 0.3.2, derive_arbitrary 1.5.0, either 1.19.0, equivalent
+1.0.2, errno 0.3.14, euclid 0.22.14, event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand
+2.5.0, flate2 1.1.10, futures-lite 2.6.1, gethostname 1.1.0, glow 0.17.0, hashbrown 0.16.1,
+hashbrown 0.17.1, hermit-abi 0.5.3, indexmap 2.14.2, itertools 0.13.0, itertools 0.15.0, js-sys
+0.3.106, khronos-egl 6.0.0, linux-raw-sys 0.12.1, linux-raw-sys 0.4.15, lock_api 0.4.14, log 0.4.34,
num-complex 0.4.6, num-traits 0.2.19, once_cell 1.21.4, ordered-stream 0.2.0, parking 2.2.1,
parking_lot 0.12.5, parking_lot_core 0.9.12, percent-encoding 2.3.2, piper 0.2.5, plain 0.2.3, png
0.18.1, polling 3.11.0, pollster 0.4.0, pollster 1.0.1, rayon 1.12.0, rayon-core 1.13.0,
@@ -2226,6 +2227,32 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+================================================================================
+base64 0.22.1
+================================================================================
+
+The MIT License (MIT)
+
+Copyright (c) 2015 Alice Maz
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
================================================================================
bit-set 0.10.0
================================================================================
diff --git a/apps/fieldcraft-web/Cargo.toml b/apps/fieldcraft-web/Cargo.toml
new file mode 100644
index 0000000..e68b258
--- /dev/null
+++ b/apps/fieldcraft-web/Cargo.toml
@@ -0,0 +1,34 @@
+[package]
+name = "fieldcraft-web"
+description = "FieldCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+publish = false
+
+[lints]
+workspace = true
+
+[[bin]]
+name = "fieldcraft-web"
+path = "src/main.rs"
+
+[dependencies]
+fieldcraft-engine = { workspace = true }
+fieldcraft-ui-egui = { workspace = true }
+fieldcraft-io = { workspace = true }
+egui = { workspace = true }
+serde_json = { workspace = true }
+log = { workspace = true }
+
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
+eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
+# `file-handle-inner`: a picked file's size, checked before it is read.
+rfd = { workspace = true, features = ["file-handle-inner"] }
+wasm-bindgen = "0.2"
+wasm-bindgen-futures = "0.4"
+js-sys = "0.3"
+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "DomTokenList", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "Storage"] }
+# Autosaves kept in the browser's local storage, which holds text.
+base64 = "0.22"
diff --git a/apps/fieldcraft-web/Trunk.toml b/apps/fieldcraft-web/Trunk.toml
new file mode 100644
index 0000000..28dc2e5
--- /dev/null
+++ b/apps/fieldcraft-web/Trunk.toml
@@ -0,0 +1,10 @@
+# Build with: trunk build --release (from apps/fieldcraft-web; docs/web.md)
+[build]
+target = "index.html"
+dist = "../../dist/web"
+public_url = "./"
+filehash = true
+
+[serve]
+addresses = ["127.0.0.1"]
+port = 8790
diff --git a/apps/fieldcraft-web/index.html b/apps/fieldcraft-web/index.html
new file mode 100644
index 0000000..081f163
--- /dev/null
+++ b/apps/fieldcraft-web/index.html
@@ -0,0 +1,54 @@
+
+
+
+
+
+ FieldCraft
+
+
+
+
+
+
+
+
+
+
+
+
+
Loading FieldCraft…
+
+
+
+
+
diff --git a/apps/fieldcraft-web/src/main.rs b/apps/fieldcraft-web/src/main.rs
new file mode 100644
index 0000000..0e31a68
--- /dev/null
+++ b/apps/fieldcraft-web/src/main.rs
@@ -0,0 +1,76 @@
+//! FieldCraft in the browser.
+//!
+//! Runs the same [`fieldcraft_ui_egui::ModelApp`] as the desktop app through eframe's web runner
+//! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this
+//! directory (output in `dist/web`); docs/web.md says what works in the browser.
+//!
+//! Differences from the desktop app, all behind the UI's host services:
+//! - files live in the page ([`fieldcraft_io::fs`] keeps them in memory): File › Open and drops
+//! read the computer's files into it; Save, Save As and Write Deck hand the written file out as
+//! a download;
+//! - autosaves for recovery are kept in the browser's local storage ([`storage`]);
+//! - no control channel (a page can't listen on a socket);
+//! - background jobs (meshing, solving) run on the page's one thread once the window has shown
+//! that they run.
+//!
+//! Address flags: `?sample` or `?sample=cantilever` opens a sample, `?webgl` forces WebGL2.
+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+#![forbid(unsafe_code)]
+
+#[cfg(target_arch = "wasm32")]
+mod storage;
+#[cfg(target_arch = "wasm32")]
+mod web;
+
+#[cfg(target_arch = "wasm32")]
+fn main() {
+ web::start();
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+fn main() {
+ eprintln!("fieldcraft-web only runs in the browser: build it with `trunk build --release` in apps/fieldcraft-web (docs/web.md)");
+}
+
+/// The sample an address asks for (`?sample` opens the first sample, `?sample=NAME` that one).
+#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
+fn sample_in_query(query: &str) -> Option {
+ let first = fieldcraft_engine::sample::SAMPLES.first().map_or("", |s| s.name);
+ query.trim_start_matches('?').split('&').find_map(|kv| {
+ let (key, value) = kv.split_once('=').unwrap_or((kv, ""));
+ (key == "sample").then(|| if value.is_empty() { first } else { value }.to_string())
+ })
+}
+
+/// The name a file from the computer is kept under in the page: its last path part, without
+/// characters a path would read as folders.
+#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
+fn safe_name(name: &str) -> String {
+ let last = name.rsplit(['/', '\\']).next().unwrap_or_default();
+ let clean: String = last.chars().filter(|c| !c.is_control()).take(200).collect();
+ if clean.is_empty() || clean == "." || clean == ".." { "file".to_string() } else { clean }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn samples_from_the_address() {
+ assert_eq!(sample_in_query(""), None);
+ assert_eq!(sample_in_query("?sample").as_deref(), Some("parts"));
+ assert_eq!(sample_in_query("?webgl&sample=cantilever").as_deref(), Some("cantilever"));
+ assert_eq!(sample_in_query("?sample=").as_deref(), Some("parts"));
+ assert_eq!(sample_in_query("?samples=x"), None);
+ }
+
+ #[test]
+ fn file_names_stay_in_their_folder() {
+ assert_eq!(safe_name("beam.fieldcraft"), "beam.fieldcraft");
+ assert_eq!(safe_name("../../etc/passwd"), "passwd");
+ assert_eq!(safe_name("C:\\work\\plate.solidcraft"), "plate.solidcraft");
+ assert_eq!(safe_name(".."), "file");
+ assert_eq!(safe_name(""), "file");
+ assert_eq!(safe_name("a\u{0}b.3mf"), "ab.3mf");
+ }
+}
diff --git a/apps/fieldcraft-web/src/storage.rs b/apps/fieldcraft-web/src/storage.rs
new file mode 100644
index 0000000..3ecec75
--- /dev/null
+++ b/apps/fieldcraft-web/src/storage.rs
@@ -0,0 +1,101 @@
+//! Autosaves kept in the browser.
+//!
+//! The engine's recovery folder is [`fieldcraft_io::recovery::BROWSER_DIR`] among the page's
+//! files, which last only as long as the page. This mirrors it to the browser's local storage
+//! (kept across visits, shared by the site's tabs): each file as base64 under
+//! [`PREFIX`]``. At start the stored files go back into the folder, so the desktop's
+//! recovery flow (File › Recover…, `file.recovery.*`) offers what a closed or crashed page left.
+//!
+//! Local storage holds a few megabytes per site: an autosave larger than [`MAX_AUTOSAVE_BYTES`]
+//! (or one the browser refuses) is not kept, and the status bar says so.
+
+use std::collections::BTreeSet;
+use std::path::Path;
+
+use base64::Engine as _;
+use base64::engine::general_purpose::STANDARD;
+use fieldcraft_io::recovery::BROWSER_DIR;
+
+const PREFIX: &str = "fieldcraft-recovery/";
+
+/// Largest stored file taken back (local storage itself holds less).
+const MAX_STORED_CHARS: usize = 64 << 20;
+
+/// Largest autosave kept: base64 makes it a third larger, and browsers give a site about 5 MB.
+pub const MAX_AUTOSAVE_BYTES: usize = 3 << 20;
+
+fn local_storage() -> Result {
+ web_sys::window().ok_or("no window")?.local_storage().map_err(crate::web::js_err)?.ok_or_else(|| "browser storage is turned off".into())
+}
+
+/// The mirror: the stored files this page knows of (took back or wrote), by file name.
+#[derive(Default)]
+pub struct Store {
+ known: BTreeSet,
+}
+
+pub fn dir() -> &'static Path {
+ Path::new(BROWSER_DIR)
+}
+
+/// A file name of the recovery folder that is safe as a key (the engine's ids and extensions).
+fn valid(name: &str) -> bool {
+ !name.is_empty() && name.len() <= 100 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.') && !name.starts_with('.')
+}
+
+impl Store {
+ /// Put the stored files back into the recovery folder.
+ pub fn load() -> Store {
+ let mut store = Store::default();
+ let s = match local_storage() {
+ Ok(s) => s,
+ Err(e) => {
+ log::warn!("autosaves: {e}");
+ return store;
+ }
+ };
+ let keys: Vec = (0..s.length().unwrap_or(0)).filter_map(|i| s.key(i).ok().flatten()).collect();
+ for key in keys {
+ let Some(name) = key.strip_prefix(PREFIX).filter(|n| valid(n)) else { continue };
+ let Some(text) = s.get_item(&key).ok().flatten().filter(|t| t.len() <= MAX_STORED_CHARS) else { continue };
+ let Ok(bytes) = STANDARD.decode(text.as_bytes()).map_err(|e| log::warn!("autosave {name} is damaged: {e}")) else { continue };
+ match fieldcraft_io::fs::insert(&dir().join(name), bytes) {
+ Ok(()) => {
+ store.known.insert(name.to_string());
+ }
+ Err(e) => log::warn!("autosave {name}: {e}"),
+ }
+ }
+ store
+ }
+
+ /// Keep a file the engine wrote in the recovery folder.
+ pub fn put(&mut self, path: &Path, bytes: &[u8]) -> Result<(), String> {
+ let name =
+ path.file_name().and_then(|n| n.to_str()).filter(|n| valid(n)).ok_or_else(|| format!("{}: not a recovery file", path.display()))?;
+ if bytes.len() > MAX_AUTOSAVE_BYTES {
+ return Err(format!("the model ({} MB) is too large for the browser's storage", bytes.len() >> 20));
+ }
+ self.known.insert(name.to_string());
+ local_storage()?
+ .set_item(&format!("{PREFIX}{name}"), &STANDARD.encode(bytes))
+ .map_err(|e| format!("browser storage is full or turned off ({})", crate::web::js_err(e)))
+ }
+
+ /// Drop the stored files whose entries left the recovery folder (cleared once saved,
+ /// recovered and discarded). Only files this page knows of: another tab's stay.
+ pub fn sync_removals(&mut self) {
+ let present: BTreeSet =
+ fieldcraft_io::fs::list(dir()).iter().filter_map(|p| p.file_name().and_then(|n| n.to_str()).map(str::to_string)).collect();
+ let gone: Vec = self.known.difference(&present).cloned().collect();
+ if gone.is_empty() {
+ return;
+ }
+ let Ok(s) = local_storage() else { return };
+ for name in gone {
+ if s.remove_item(&format!("{PREFIX}{name}")).is_ok() {
+ self.known.remove(&name);
+ }
+ }
+ }
+}
diff --git a/apps/fieldcraft-web/src/web.rs b/apps/fieldcraft-web/src/web.rs
new file mode 100644
index 0000000..4e816a2
--- /dev/null
+++ b/apps/fieldcraft-web/src/web.rs
@@ -0,0 +1,326 @@
+//! The browser shell: the web services of the UI (file pickers, downloads), dropped files, the
+//! autosave, and eframe's web runner.
+//!
+//! Structure follows VectorCraft's `apps/vectorcraft-web/src/web.rs` and SolidCraft's
+//! `apps/solidcraft-web/src/main.rs` (MIT OR Apache-2.0), see ATTRIBUTION.md.
+
+use std::cell::{Cell, RefCell};
+use std::path::Path;
+use std::rc::Rc;
+
+use fieldcraft_engine::Session;
+use fieldcraft_engine::autosave::Autosaver;
+use fieldcraft_ui_egui::files::Kind;
+use fieldcraft_ui_egui::{Filter, ModelApp, Services};
+use wasm_bindgen::JsCast as _;
+
+use crate::storage::{self, Store};
+
+const CANVAS_ID: &str = "fieldcraft_canvas";
+const LOADING_ID: &str = "fieldcraft_loading";
+const DEPTH_BITS: u8 = 24;
+
+/// The page's folder for files from the computer and the files commands write.
+const FILES_DIR: &str = "/files";
+
+/// How often unsaved changes are autosaved for recovery (seconds), as on the desktop.
+const AUTOSAVE_SECONDS: f64 = 30.0;
+
+/// Files read from the computer (picked together or dropped together), waiting for the next
+/// frame: (name, bytes or why they could not be read) each.
+type Batch = Vec<(String, Result, String>)>;
+type Inbox = Rc>>;
+
+/// Largest file read from the computer: what the page can hold.
+const MAX_READ_BYTES: f64 = fieldcraft_io::memory::MAX_TOTAL_BYTES as f64;
+
+/// Why a file of `size` bytes is not read, if it is too large.
+fn too_large(size: f64) -> Option {
+ (size > MAX_READ_BYTES)
+ .then(|| format!("{} MB is more than this page holds ({} MB)", (size / 1048576.0).round(), fieldcraft_io::memory::MAX_TOTAL_BYTES >> 20))
+}
+
+/// Hand files read from the computer to the next frame.
+fn deliver(inbox: &Inbox, ctx: &egui::Context, batch: Batch) {
+ inbox.borrow_mut().push(batch);
+ ctx.request_repaint();
+}
+
+/// Where a file named `name` is kept in the page.
+fn page_path(name: &str) -> String {
+ format!("{FILES_DIR}/{}", crate::safe_name(name))
+}
+
+thread_local! {
+ /// The model has unsaved changes: leaving the page asks first.
+ static UNSAVED: Cell = const { Cell::new(false) };
+}
+
+pub fn js_err(e: wasm_bindgen::JsValue) -> String {
+ e.as_string().unwrap_or_else(|| format!("{e:?}"))
+}
+
+fn query() -> String {
+ web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
+}
+
+fn element(id: &str) -> Option {
+ web_sys::window()?.document()?.get_element_by_id(id)
+}
+
+pub fn start() {
+ eframe::WebLogger::init(log::LevelFilter::Info).ok();
+ // The browser's clock for the recovery entries (the plain wasm target has none).
+ fieldcraft_io::recovery::set_clock(|| js_sys::Date::now().max(0.0) as u64);
+ let Some(window) = web_sys::window() else { return };
+ ask_before_unload(&window);
+ let Some(canvas) = element(CANVAS_ID).and_then(|e| e.dyn_into::().ok()) else {
+ log::error!("missing