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 "); + return; + }; + wasm_bindgen_futures::spawn_local(async move { + let result = eframe::WebRunner::new() + .start( + canvas, + web_options(), + Box::new(|cc| { + let inbox = Inbox::default(); + let mut app = ModelApp::new(Session::default()).with_services(services(inbox.clone(), cc.egui_ctx.clone())); + if let Some(rs) = &cc.wgpu_render_state { + let info = rs.adapter.get_info(); + log::info!("fieldcraft-web: graphics {:?} {}, MSAA {}x", info.backend, info.name, rs.msaa_samples); + app.set_wgpu(rs, DEPTH_BITS, rs.msaa_samples, &cc.egui_ctx); + } + // The autosaves a closed or crashed page left, back in the recovery folder. + let store = Store::load(); + let listed = app.session.execute("file.recovery.list", &serde_json::json!({})); + let recoverable = listed.ok().and_then(|v| v.as_array().map(Vec::len)).unwrap_or(0); + let autosaver = Autosaver::new(storage::dir()).map_err(|e| log::warn!("autosave unavailable: {e}")).ok(); + if let Some(name) = crate::sample_in_query(&query()) { + app.open_sample(&name); + } + if recoverable > 0 { + app.set_status(format!("{recoverable} autosaved model(s) can be recovered: File › Recover…"), false); + } + Ok(Box::new(Shell { app, inbox, store, autosaver, autosaved_at: 0.0, storage_error: false })) + }), + ) + .await; + match result { + Ok(()) => { + if let Some(el) = element(LOADING_ID) { + el.remove(); + } + } + Err(e) => failed(&format!("FieldCraft could not start: {}", js_err(e))), + } + }); +} + +/// The runner's options: wgpu (WebGPU where available, else WebGL2; `?webgl` forces WebGL2). +fn web_options() -> eframe::WebOptions { + let mut options = eframe::WebOptions { depth_buffer: DEPTH_BITS, ..Default::default() }; + if query().split(['?', '&']).any(|kv| kv == "webgl") + && let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup + { + create.instance_descriptor.backends = eframe::wgpu::Backends::GL; + } + options +} + +/// Replace the loading screen with why the app could not start. +fn failed(why: &str) { + log::error!("{why}"); + let Some(el) = element(LOADING_ID) else { return }; + let Some(document) = web_sys::window().and_then(|w| w.document()) else { return }; + el.set_text_content(None); + // Best effort: the console has the message too. + let _ = el.class_list().add_1("failed"); + for line in + [why, "FieldCraft needs a browser with WebGPU or WebGL2 (a current Chrome, Edge, Firefox or Safari) and graphics acceleration turned on."] + { + if let Ok(p) = document.create_element("p") { + p.set_text_content(Some(line)); + let _ = el.append_child(&p); + } + } +} + +/// The browser's "leave site? changes you made may not be saved" prompt while the model has +/// unsaved changes (a page that is closing can't show its own dialog). +fn ask_before_unload(window: &web_sys::Window) { + let on_unload = wasm_bindgen::closure::Closure::::new(|e: web_sys::BeforeUnloadEvent| { + if UNSAVED.with(Cell::get) { + e.prevent_default(); + e.set_return_value("unsaved"); + } + }); + if let Err(e) = window.add_event_listener_with_callback("beforeunload", on_unload.as_ref().unchecked_ref()) { + log::warn!("couldn't ask before leaving: {}", js_err(e)); + } + // The listener lives as long as the page. + on_unload.forget(); +} + +/// The UI's host services in the browser. +fn services(inbox: Inbox, ctx: egui::Context) -> Services { + Services { + // The browser's file picker: the files arrive in a later frame (the picker and the reads + // are asynchronous), so the call itself picks nothing. + pick_open: Some(Box::new(move |filters: &[Filter]| { + let (inbox, ctx) = (inbox.clone(), ctx.clone()); + let dialog = filters.iter().fold(rfd::AsyncFileDialog::new(), |d, f| d.add_filter(f.name, f.extensions)); + wasm_bindgen_futures::spawn_local(async move { + let Some(files) = dialog.pick_files().await else { return }; + let mut batch = Vec::with_capacity(files.len()); + for f in files { + let read = match too_large(f.inner().size()) { + Some(why) => Err(why), + None => Ok(f.read().await), + }; + batch.push((f.file_name(), read)); + } + deliver(&inbox, &ctx, batch); + }); + None + })), + // Saving writes into the page; the shell hands each written file out as a download. + pick_save: Some(Box::new(|name: &str, _: &[Filter]| Some(page_path(name)))), + // The recent list holds the page's files, which go with the page. + recent_path: None, + } +} + +/// The app with the browser's files around it. +struct Shell { + app: ModelApp, + inbox: Inbox, + store: Store, + /// `None` when autosave could not start. + autosaver: Option, + /// App time of the last autosave check. + autosaved_at: f64, + /// The last autosave did not fit in the browser's storage (said once until one fits). + storage_error: bool, +} + +impl Shell { + /// Files read from the computer go into the page's folder, then open: a model with the + /// design and meshes it refers to picked or dropped with it, else each file in turn. + fn open_arrived(&mut self) { + let batches: Vec<_> = self.inbox.borrow_mut().drain(..).collect(); + for batch in batches { + let mut paths = Vec::with_capacity(batch.len()); + for (name, read) in batch { + let path = page_path(&name); + match read.and_then(|bytes| fieldcraft_io::fs::insert(Path::new(&path), bytes).map_err(|e| e.to_string())) { + Ok(()) => paths.push(path), + Err(e) => self.app.set_status(format!("{name}: {e}"), true), + } + } + let model = paths.iter().find(|p| Kind::of(p) == Some(Kind::Model)).cloned(); + match model { + // Its geometry files open with it, from the same folder. + Some(model) => self.app.open_path(&model), + None => { + for p in &paths { + self.app.open_path(p); + } + } + } + } + } + + /// What commands wrote: autosaves to the browser's storage, everything else downloaded. + fn hand_out_writes(&mut self) { + for (path, bytes) in fieldcraft_io::fs::take_writes() { + if path.parent() == Some(storage::dir()) { + match self.store.put(&path, &bytes) { + Ok(()) => self.storage_error = false, + Err(e) if !self.storage_error => { + self.storage_error = true; + self.app.set_status(format!("Autosave: {e}. Save the model to keep it (File › Save downloads it)"), true); + } + Err(e) => log::warn!("autosave: {e}"), + } + } else if let Err(e) = download(&path, &bytes) { + self.app.set_status(format!("{}: the download did not start ({e})", path.display()), true); + } + } + self.store.sync_removals(); + } + + fn autosave(&mut self, ctx: &egui::Context) { + let Some(a) = &mut self.autosaver else { return }; + if self.app.now - self.autosaved_at >= AUTOSAVE_SECONDS { + self.autosaved_at = self.app.now; + if let Err(e) = a.save(&self.app.session) { + log::warn!("autosave: {e}"); + } + } + ctx.request_repaint_after(std::time::Duration::from_secs_f64(AUTOSAVE_SECONDS)); + } +} + +impl eframe::App for Shell { + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.open_arrived(); + self.app.logic(ctx); + self.autosave(ctx); + UNSAVED.with(|u| u.set(self.app.session.is_dirty() && !self.app.session.journal.is_empty())); + } + + /// Dropped files are read here (browsers read them asynchronously) and arrive through the + /// inbox; the app never sees their names as paths. + fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + let dropped = std::mem::take(&mut raw.dropped_files); + if !dropped.is_empty() { + let (inbox, ctx) = (self.inbox.clone(), ctx.clone()); + wasm_bindgen_futures::spawn_local(async move { + let mut batch = Vec::with_capacity(dropped.len()); + for f in dropped { + let name = f.path().to_string_lossy().to_string(); + let read = match f.web_file().and_then(|w| too_large(w.size())) { + Some(why) => Err(why), + None => f.bytes_async().await, + }; + batch.push((name, read)); + } + deliver(&inbox, &ctx, batch); + }); + } + self.app.raw_input_hook(raw); + } + + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + self.app.ui(ui); + // Once a frame, after everything that writes: autosaves, saves and decks. + self.hand_out_writes(); + } +} + +/// Hand `bytes` out as a download named after the last part of `path`. +fn download(path: &Path, bytes: &[u8]) -> Result<(), String> { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("fieldcraft").to_string(); + let window = web_sys::window().ok_or("no window")?; + let document = window.document().ok_or("no document")?; + let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); + let options = web_sys::BlobPropertyBag::new(); + options.set_type("application/octet-stream"); + let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &options).map_err(js_err)?; + let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js_err)?; + let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js_err)?.dyn_into().map_err(|_| "not a link")?; + a.set_href(&url); + a.set_download(&name); + a.style().set_property("display", "none").map_err(js_err)?; + document.body().ok_or("no body")?.append_child(&a).map_err(js_err)?; + a.click(); + a.remove(); + // Revoked once the click has been dispatched; the download keeps its own reference. + let revoke = wasm_bindgen::closure::Closure::once_into_js(move || { + let _ = web_sys::Url::revoke_object_url(&url); + }); + // The download has started: a timer that can't be set leaves the URL allocated, no more. + let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000); + Ok(()) +} diff --git a/docs/architecture.md b/docs/architecture.md index ad3f2a4..7c116bf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -34,7 +34,7 @@ crates in strictly lower layers. | L6 | `fieldcraft-engine` | Commands (`CommandSpec`), `Session::execute` with rollback and the panic guard, scripts; every file a command names checked against the session's roots (`Session::file_access`); headless pictures of the session (`view`: model, geometry, deformed shape and contours on the CPU rasterizer). | started | | L7 | `fieldcraft-ui-egui`, `fieldcraft-mcp` | Desktop UI in the Craft family's layout (title bar with quick actions, workspace ribbon from the catalogue, browser, viewport with navigation bar and orientation gizmo, context panel, command search and command line, journal timeline; native file pickers as host services, background opening and solving; results display with legend and probe; offscreen capture of the whole window) and control channel requests (docs/control-protocol.md); MCP server over stdio (docs/mcp.md): tools over engine commands, a headless session or the running app through its control channel, read and write roots. | ui, mcp started | | testkit | `fieldcraft-testkit` | Test tooling, a dev-dependency only: the meshing test corpus (`tests/corpus/*/recipe.json`, analytic shapes) and its runner (`cargo xtask corpus`). | started | -| apps | `fieldcraft`, `fieldcraft-cli`, `fieldcraft-web` | Desktop, command line, browser. | desktop, cli started | +| apps | `fieldcraft`, `fieldcraft-cli`, `fieldcraft-web` | Desktop, command line, browser (docs/web.md). | started | Rules beyond the layer order: - Nothing below L7 uses egui, eframe, winit, wgpu or rfd. diff --git a/docs/web.md b/docs/web.md new file mode 100644 index 0000000..5a4d21c --- /dev/null +++ b/docs/web.md @@ -0,0 +1,77 @@ +# FieldCraft in the browser + +`apps/fieldcraft-web` runs the desktop app's interface (`fieldcraft_ui_egui::ModelApp`) in a web +page through eframe's web runner, drawing with WebGPU where the browser has it and WebGL2 +otherwise. The engine, the meshers and the solvers are the same code, compiled to +`wasm32-unknown-unknown`. + +Live: , deployed from each release (a push to `main`, +`.github/workflows/pages.yml`). + +## Run it locally + +```sh +rustup target add wasm32-unknown-unknown +cargo install --locked trunk # or a release binary from github.com/trunk-rs/trunk +cd apps/fieldcraft-web +trunk serve # http://127.0.0.1:8790, rebuilt on every change +trunk serve --release # as deployed (optimised, wasm-opt) +trunk build --release # the site in dist/web, as Pages gets it +``` + +Trunk fetches the matching `wasm-bindgen` and `wasm-opt` itself. + +Address flags: `?sample` (or `?sample=cantilever`, `?sample=impact`) opens a sample; `?webgl` +draws with WebGL2 even where WebGPU is available. + +CI keeps the crate compiling for the browser: `cargo xtask wasm` (part of `cargo xtask ci`) +checks `fieldcraft-web` with the library crates, and `pages.yml` builds the whole site with +Trunk on every merge to `develop` that touches code, without deploying it. + +## What works + +Everything that runs on a model: opening samples, opening SolidCraft designs and 3MF tagged +meshes, every mesher, materials, sections, steps, loads and boundary conditions, the implicit, +explicit and lattice Boltzmann solvers, the results display (contours, deformed shape, probe, +reaction sums), undo and redo, the command line and command search, and writing Abaqus, Nastran +and LS-DYNA decks. The solvers run on one thread in the browser; `fieldcraft-parallel` gives the +same numbers for any thread count (docs/adr/0001), so results are the desktop's. + +The browser has no file system the app can reach, so files live in the page +(`fieldcraft_io::fs` keeps them in memory) and the host services move them in and out: + +| Desktop | Browser | +|---|---| +| File › Open, a native picker | The browser's file picker; pick a model together with the design and meshes it refers to | +| Drag and drop | Drop files on the page; drop a model together with its geometry files | +| Save, Save As | The `.fieldcraft` file is downloaded (each save downloads it again) | +| Write Deck | The deck is downloaded | +| Autosave to the recovery folder, File › Recover… | Autosaves are kept in the browser's local storage; a page closed or crashed with unsaved changes offers them at the next visit (File › Recover…) | +| Leaving with unsaved changes asks first | The browser's "leave site?" prompt | +| Recent files | This visit's files only | + +A `.fieldcraft` model refers to its design and meshes by path. Paths in the model's own folder +are stored relative to it, so picking or dropping the model with those files opens them again; a +model whose geometry is elsewhere opens without it and says so in the status bar. + +## What doesn't + +- **Long jobs block the page while they run.** The browser build has no worker threads: a mesher + or a solve started from its form shows its progress first, then runs on the page's thread, and + the page stops responding until it finishes (seconds for the samples, longer for large parts). + Cancel does nothing once it runs. Worker threads need `SharedArrayBuffer`, which needs + cross-origin isolation headers that GitHub Pages cannot send. +- **No control channel or MCP bridge**: a page cannot listen on a socket. Agents use the desktop + app or `fieldcraft-cli mcp`. +- **Autosave space is small**: local storage holds a few megabytes per site, so the autosave of a + large mesh may not fit; the status bar then says so, and saving (a download) keeps the work. +- **Two tabs share one store**: a tab offers the autosave of another FieldCraft tab that is still + open as recoverable (recovering it opens a copy). +- **A lost GPU is not recovered**: if the browser takes the graphics device away, reload the page + (unsaved work is in the autosave). +- **Download size**: about 26 MB of WebAssembly (10 MB compressed), loaded once per release. + +## Requirements + +A current Chrome, Edge, Firefox or Safari with WebGPU or WebGL2 and graphics acceleration turned +on. Without either the page says so instead of starting. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 834af96..0157abb 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -43,7 +43,7 @@ commands: against their references and fixtures; write docs/oracle.md after a full run; --bless freezes a passing case's results in its fixture.json; --quick skips cases marked slow (release build) - wasm cargo check every library crate for wasm32-unknown-unknown + wasm cargo check every library crate and the web app for wasm32-unknown-unknown ci fmt --check, assets, licences --check, layers, clippy -D warnings, then the tests (not the heavy ones) with the wasm check alongside (stops at the first failing step) @@ -160,7 +160,8 @@ fn cmd_layers() -> Result<(), String> { } } -/// Never break wasm: every layered crate (everything below the apps) must build for the browser. +/// Never break wasm: every layered crate (everything below the apps) and the web app must build +/// for the browser. fn cmd_wasm() -> Result<(), String> { let (c, n) = wasm_check()?; run(c, &format!("cargo check --target wasm32-unknown-unknown ({n} crates)"))?; @@ -168,12 +169,21 @@ fn cmd_wasm() -> Result<(), String> { Ok(()) } +/// The app that runs in the browser (apps/fieldcraft-web), checked with the layered crates. +const WEB_APP: &str = "fieldcraft-web"; + /// The wasm check and how many crates it covers. It uses the ci profile so the host build /// scripts and proc macros built for clippy and the tests are reused. fn wasm_check() -> Result<(Command, usize), String> { let crates = layers::from_metadata(&metadata()?)?; - let set: Vec = - crates.iter().filter(|c| matches!(layers::classify(&c.name), Some(layers::Class::Layer(_)))).map(|c| c.name.clone()).collect(); + let set: Vec = crates + .iter() + .filter(|c| c.name == WEB_APP || matches!(layers::classify(&c.name), Some(layers::Class::Layer(_)))) + .map(|c| c.name.clone()) + .collect(); + if !set.iter().any(|c| c == WEB_APP) { + return Err(format!("wasm: the web app `{WEB_APP}` is not in the workspace")); + } let mut c = cargo(); c.args(["check", "--target", "wasm32-unknown-unknown", "--profile", "ci"]); for p in &set {