From a737ddad3bdc1c354c139581e6baaffd398e4b95 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 7 Oct 2026 20:35:55 +0000 Subject: [PATCH] Web: apps/solvecraft-web (Trunk, eframe web runner, WebGPU or WebGL2), cargo xtask wasm in ci, web-time clocks Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- Cargo.lock | 16 ++++++ Cargo.toml | 2 + README.md | 5 +- ROADMAP.md | 4 +- apps/solvecraft-web/Cargo.toml | 26 ++++++++++ apps/solvecraft-web/Trunk.toml | 10 ++++ apps/solvecraft-web/index.html | 19 +++++++ apps/solvecraft-web/src/main.rs | 91 +++++++++++++++++++++++++++++++++ crates/doc/Cargo.toml | 1 + crates/doc/src/eval.rs | 4 +- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/lib.rs | 2 +- xtask/src/layers.rs | 1 + xtask/src/main.rs | 22 +++++++- 14 files changed, 197 insertions(+), 7 deletions(-) create mode 100644 apps/solvecraft-web/Cargo.toml create mode 100644 apps/solvecraft-web/Trunk.toml create mode 100644 apps/solvecraft-web/index.html create mode 100644 apps/solvecraft-web/src/main.rs 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 "); + return; + }; + let mut options = eframe::WebOptions { depth_buffer: DEPTH_BITS, ..Default::default() }; + if query().contains("webgl") + && let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup + { + create.instance_descriptor.backends = eframe::wgpu::Backends::GL; + } + let result = eframe::WebRunner::new() + .start( + canvas, + options, + Box::new(move |cc| { + let mut app = SolveApp::new(Session::default(), Services::default()); + if let Some(rs) = &cc.wgpu_render_state { + app.set_wgpu(rs, DEPTH_BITS, 1); + } + if query().contains("sample") { + if let Err(e) = app.session.run_script(&solvecraft_engine::sample::script()) { + app.set_status(format!("sample: {e}"), true); + } + app.session.doc_mut().name = "Sample Plate".into(); + app.session.mark_saved(); + } + Ok(Box::new(Shell(app))) + }), + ) + .await; + if let Some(el) = document.get_element_by_id(LOADING_ID) { + match result { + Ok(()) => el.remove(), + Err(e) => { + el.set_inner_html(&format!("

SolveCraft failed to start: {e:?}

A browser with WebGPU or WebGL2 is required.

")) + } + } + } + }); + } +} + +#[cfg(target_arch = "wasm32")] +fn main() { + web::start(); +} + +#[cfg(not(target_arch = "wasm32"))] +fn main() { + eprintln!("solvecraft-web only runs in the browser: build it with `trunk build --release` in apps/solvecraft-web"); +} diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml index 90edb501..1ebce064 100644 --- a/crates/doc/Cargo.toml +++ b/crates/doc/Cargo.toml @@ -16,3 +16,4 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +web-time = { workspace = true } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index a5d5eefa..01f4c8cf 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -115,8 +115,8 @@ fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap, rolled_ba h.finish() } -fn now() -> std::time::Instant { - std::time::Instant::now() +fn now() -> web_time::Instant { + web_time::Instant::now() } impl Model { diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 02d70d7d..df204f1e 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -15,4 +15,5 @@ egui-wgpu = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +web-time = { workspace = true } image = { workspace = true } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0a05dc3b..e9571bab 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -441,6 +441,6 @@ impl SolveApp { } pub fn now_ms() -> f64 { - use std::time::{SystemTime, UNIX_EPOCH}; + use web_time::{SystemTime, UNIX_EPOCH}; SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) } diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index c381629e..0a65c1e8 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -48,6 +48,7 @@ pub const TABLE: &[(&str, Class)] = &[ // apps and tooling ("solvecraft", Class::Exempt), ("cli", Class::Exempt), + ("web", Class::Exempt), ("xtask", Class::Exempt), ]; diff --git a/xtask/src/main.rs b/xtask/src/main.rs index e6bcd9a6..1e03cd3c 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -21,7 +21,8 @@ commands: xtask/data/fusion-catalog.tsv from plan/fusion/menu-tree.json) oracle replay plan/fusion/oracle/*/recipe.json and compare with measure.json; writes docs/oracle.md - ci fmt --check, clippy -D warnings, test, assets, layers (stops at first failure) + wasm cargo check every library crate and the web app for wasm32-unknown-unknown + ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure) "; fn main() -> ExitCode { @@ -33,6 +34,7 @@ fn main() -> ExitCode { Some("parity") => parity::run(&root(), rest.contains(&"--refresh")), Some("oracle") => cmd_oracle(), Some("ci") => cmd_ci(), + Some("wasm") => cmd_wasm(), Some("-h" | "--help" | "help") | None => { print!("{USAGE}"); Ok(()) @@ -161,6 +163,23 @@ fn cmd_oracle() -> Result<(), String> { Ok(()) } +/// 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 crates = layers::from_metadata(&metadata()?)?; + let mut set: Vec = + crates.iter().filter(|c| matches!(layers::classify(&c.name), Some(layers::Class::Layer(_)))).map(|c| c.name.clone()).collect(); + set.push("solvecraft-web".into()); + let mut c = cargo(); + c.args(["check", "--target", "wasm32-unknown-unknown"]); + for p in &set { + c.args(["-p", p]); + } + run(c, &format!("cargo check --target wasm32-unknown-unknown ({} crates)", set.len()))?; + println!("wasm: {} crates build for wasm32-unknown-unknown", set.len()); + Ok(()) +} + fn cmd_ci() -> Result<(), String> { let mut c = cargo(); c.args(["fmt", "--all", "--", "--check"]); @@ -173,6 +192,7 @@ fn cmd_ci() -> Result<(), String> { run(c, "cargo test")?; assets::run(&root())?; cmd_layers()?; + cmd_wasm()?; eprintln!("ci: all gates passed"); Ok(()) }