diff --git a/Cargo.lock b/Cargo.lock
index ac5a619b..24f3617e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3084,6 +3084,7 @@ dependencies = [
"solvecraft-kernel",
"solvecraft-sketch",
"thiserror 2.0.21",
+ "web-time",
]
[[package]]
@@ -3173,6 +3174,21 @@ dependencies = [
"serde",
"serde_json",
"solvecraft-engine",
+ "web-time",
+]
+
+[[package]]
+name = "solvecraft-web"
+version = "0.1.0"
+dependencies = [
+ "eframe",
+ "egui",
+ "log",
+ "solvecraft-engine",
+ "solvecraft-ui-egui",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 5b4a6bba..339c4fa0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -41,6 +41,8 @@ image = { version = "0.25", default-features = false, features = ["png"] }
# ui
egui = "0.36"
eframe = { version = "0.36", default-features = false }
+# Instant/SystemTime that also work in the browser (std panics on wasm32-unknown-unknown).
+web-time = "1"
egui-wgpu = "0.36"
rfd = "0.17"
diff --git a/README.md b/README.md
index ed224998..477e44f9 100644
--- a/README.md
+++ b/README.md
@@ -45,11 +45,14 @@ cargo run --release -p solvecraft -- --sample # desktop app with a samp
cargo run --release -p solvecraft-cli -- commands # the command registry
cargo run --release -p solvecraft-cli -- run examples/bracket.json --out bracket.step
cargo run --release -p solvecraft-cli -- snapshot examples/bracket.json --out bracket.png
-cargo xtask ci # fmt, clippy, tests, assets, layers
+cargo xtask ci # fmt, clippy, tests, assets, layers, wasm
```
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
+In the browser: `cd apps/solvecraft-web && trunk build --release --public-url ./` (WebGPU, or
+WebGL2 with `?webgl`; `?sample` opens the sample part).
+
## Scripts
A script is a JSON list of commands, the same ones the UI runs:
diff --git a/ROADMAP.md b/ROADMAP.md
index a68596ab..8c50aeba 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -7,11 +7,11 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
| Question | Answer |
|---|---|
-| Where are we? | **M0 done; M1/M2 in progress.** Sketch with solver, extrude (taper, two-sided, through all), revolve, fillet/chamfer, holes, patterns, mirror, shell, draft, loft, sweep, split, construction planes, parameters that rebuild the timeline, STEP/STL export, desktop app with Fusion-style selection (hover/selected highlights, origin planes, click-to-pick sketch planes, multi-select, window/crossing box selection, animated view cube), CLI, MCP server, oracle harness. |
+| Where are we? | **M0 done; M1/M2 in progress.** Sketch with solver, extrude (taper, two-sided, through all), revolve, fillet/chamfer, holes, patterns, mirror, shell, draft, loft, sweep, split, construction planes, parameters that rebuild the timeline, STEP/STL export, desktop and browser (wasm) app with Fusion-style selection (hover/selected highlights, origin planes, click-to-pick sketch planes, multi-select, window/crossing box selection, animated view cube), CLI, MCP server, oracle harness. |
| Command parity (SOLID + SKETCH toolbar) | **57 / 291 (20%)** — [docs/parity.md](docs/parity.md) |
| Fusion oracle | **23 / 29 parts match** Fusion's measurements — [docs/oracle.md](docs/oracle.md) |
| Tests | 85 (solver, profiles, kernel booleans/blends/measures, expressions, timeline, file formats, camera, engine end-to-end, hostile-input fuzz over every command) |
-| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering — green |
+| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
| Weighted parity estimate | **≈ 5%** of Fusion's Design workspace by importance (sketch + basic solids are the core, but surfaces, assemblies, sheet metal, CAM, drawings are untouched) |
| Time to a useful alpha (M0–M6) | ≈ **150 agent hours** remain |
diff --git a/apps/solvecraft-web/Cargo.toml b/apps/solvecraft-web/Cargo.toml
new file mode 100644
index 00000000..0a093c3c
--- /dev/null
+++ b/apps/solvecraft-web/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "solvecraft-web"
+description = "SolveCraft 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 = "solvecraft-web"
+path = "src/main.rs"
+
+[dependencies]
+solvecraft-engine = { workspace = true }
+solvecraft-ui-egui = { workspace = true }
+egui = { workspace = true }
+log = { workspace = true }
+
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
+wasm-bindgen = "0.2"
+wasm-bindgen-futures = "0.4"
+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location"] }
diff --git a/apps/solvecraft-web/Trunk.toml b/apps/solvecraft-web/Trunk.toml
new file mode 100644
index 00000000..4094dea2
--- /dev/null
+++ b/apps/solvecraft-web/Trunk.toml
@@ -0,0 +1,10 @@
+# Build with: trunk build --release (from apps/solvecraft-web)
+[build]
+target = "index.html"
+dist = "../../dist/web"
+public_url = "./"
+filehash = true
+
+[serve]
+addresses = ["127.0.0.1"]
+port = 8781
diff --git a/apps/solvecraft-web/index.html b/apps/solvecraft-web/index.html
new file mode 100644
index 00000000..0145f952
--- /dev/null
+++ b/apps/solvecraft-web/index.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+ SolveCraft
+
+
+
+
+
+ Loading SolveCraft…
+
+
diff --git a/apps/solvecraft-web/src/main.rs b/apps/solvecraft-web/src/main.rs
new file mode 100644
index 00000000..57684061
--- /dev/null
+++ b/apps/solvecraft-web/src/main.rs
@@ -0,0 +1,91 @@
+//! SolveCraft in the browser.
+//!
+//! Runs the same [`solvecraft_ui_egui::SolveApp`] 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`). URL flags: `?webgl` forces WebGL2, `?sample` opens the
+//! sample part.
+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+
+#[cfg(target_arch = "wasm32")]
+mod web {
+ use solvecraft_engine::Session;
+ use solvecraft_ui_egui::{Services, SolveApp};
+ use wasm_bindgen::JsCast as _;
+
+ const CANVAS_ID: &str = "solvecraft_canvas";
+ const LOADING_ID: &str = "solvecraft_loading";
+ const DEPTH_BITS: u8 = 24;
+
+ struct Shell(SolveApp);
+
+ impl eframe::App for Shell {
+ fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
+ self.0.logic(ctx);
+ }
+ fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
+ self.0.raw_input_hook(raw);
+ }
+ fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
+ self.0.ui(ui);
+ }
+ }
+
+ fn query() -> String {
+ web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
+ }
+
+ pub fn start() {
+ eframe::WebLogger::init(log::LevelFilter::Info).ok();
+ wasm_bindgen_futures::spawn_local(async {
+ let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
+ let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::().ok()) else {
+ log::error!("missing