From b2e20c95fed1a4d59d0184d085f05f3dd5034212 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 16 Jun 2026 13:01:19 +0300 Subject: [PATCH] feat(wasm): compile the library for wasm32 (issue #45 groundwork) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step toward a web build: the library now compiles for wasm32-unknown-unknown with --no-default-features. - Add a `solid3d` feature (default on) gating truck-meshalgo / truck-shapeops / lzma-sys. These pull vtkio → xz2 → lzma-sys (C), which cannot cross-compile to wasm. Without the feature the 3-D solid path (tessellation, booleans, ACIS import) becomes no-ops; 2-D CAD is unaffected. Native builds keep the feature by default. - Move native-only crates (ureq, open) to non-wasm target deps and add cfg shims: update-check is a no-op on wasm, and a new `sys` module provides open_url + handle_path (rfd FileHandle → PathBuf; synthesizes a name-based path on the web). Native build + all 75 tests unchanged. Browser runtime wiring (entry point, trunk, real file I/O) is a follow-up. Co-Authored-By: Claude Opus 4.8 --- Cargo.toml | 21 ++++++++++++++++----- src/app/commands.rs | 6 +++--- src/app/update.rs | 16 ++++++++-------- src/io/mod.rs | 6 +++--- src/io/pdf_export.rs | 2 +- src/lib.rs | 1 + src/main.rs | 1 + src/scene/convert/mod.rs | 16 ++++++++++++++++ src/scene/convert/truck_tess.rs | 26 ++++++++++++++++++++++++++ src/scene/model/solid_model.rs | 8 ++++++++ src/sys.rs | 31 +++++++++++++++++++++++++++++++ src/update_check.rs | 9 +++++++++ 12 files changed, 123 insertions(+), 20 deletions(-) create mode 100644 src/sys.rs diff --git a/Cargo.toml b/Cargo.toml index 5c624f7a3..fae4e8e29 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,15 @@ members = ["crates/ocs_plugin_api"] [target.'cfg(windows)'.build-dependencies] winresource = "0.1" +[features] +default = ["solid3d"] +# 3-D solid modelling: B-rep tessellation (truck-meshalgo) and booleans +# (truck-shapeops). Disabled for wasm builds — truck-meshalgo pulls +# vtkio → xz2 → lzma-sys (a C library) that does not cross-compile to +# wasm32. Without this feature the app is a 2-D CAD editor; the Model tab, +# solid tessellation and ACIS import become no-ops. +solid3d = ["dep:truck-meshalgo", "dep:truck-shapeops", "dep:lzma-sys"] + [dependencies] # Stable, dependency-free add-on contract (manifest + ribbon/CadModule types). # Plugin authors target this crate's semver, not OpenCADStudio internals. @@ -22,23 +31,21 @@ ocs_plugin_api = { path = "crates/ocs_plugin_api" } # the .app crashes on launch on machines without that exact library (#56). # Forcing the `static` feature builds liblzma from the bundled source, making # the binary self-contained on every platform. -lzma-sys = { version = "0.1", features = ["static"] } +lzma-sys = { version = "0.1", features = ["static"], optional = true } iced = { version = "0.14", features = ["image", "svg", "advanced", "canvas", "smol"] } bytemuck = { version = "1.25", features = ["derive"] } glam = { version = "0.33", features = ["bytemuck"] } truck-modeling = "0.6" -truck-meshalgo = { version = "0.4", default-features = false, features = ["tessellation"] } +truck-meshalgo = { version = "0.4", default-features = false, features = ["tessellation"], optional = true } truck-polymesh = "0.6" rfd = "0.17" acadrust = "0.3.4" -open = "5" printpdf = "0.9.1" flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } rayon = "1" -ureq = { version = "3", default-features = false, features = ["rustls"] } inventory = "0.3" -truck-shapeops = "0.4" +truck-shapeops = { version = "0.4", optional = true } # Fast non-cryptographic hasher. Handle/u64 keys dominate the hot block, # hatch, draw-depth and wire caches; the default SipHash is overkill there. rustc-hash = "2" @@ -68,3 +75,7 @@ windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_Window # Z-decode fix, and CANNOSCALE/CANNOSCALEVALUE header support on top of # upstream. Revert to upstream once those PRs land. acadrust = { git = "https://github.com/HakanSeven12/acadrust", branch = "main" } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +open = "5" +ureq = { version = "3", default-features = false, features = ["rustls"] } diff --git a/src/app/commands.rs b/src/app/commands.rs index 8f35b9d68..a5556ac8b 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -2489,7 +2489,7 @@ impl OpenCADStudio { } "DONATE" => { - let _ = open::that("https://patreon.com/HakanSeven12"); + crate::sys::open_url("https://patreon.com/HakanSeven12"); self.command_line.push_info("Opening Patreon page..."); } @@ -2669,7 +2669,7 @@ impl OpenCADStudio { } "REPORT" => { - let _ = open::that("https://github.com/HakanSeven12/OpenCADStudio/issues/new"); + crate::sys::open_url("https://github.com/HakanSeven12/OpenCADStudio/issues/new"); self.command_line.push_info("Opening feedback page..."); } @@ -2682,7 +2682,7 @@ impl OpenCADStudio { } "CHANGELOG" => { - let _ = open::that("https://github.com/HakanSeven12/OpenCADStudio/releases"); + crate::sys::open_url("https://github.com/HakanSeven12/OpenCADStudio/releases"); self.command_line.push_info("Opening release notes..."); } diff --git a/src/app/update.rs b/src/app/update.rs index d6d347489..41ce74fd7 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -408,7 +408,7 @@ impl OpenCADStudio { .pick_file() .await; match handle { - Some(h) => Ok(h.path().to_path_buf()), + Some(h) => Ok(crate::sys::handle_path(&h)), None => Err("Cancelled".to_string()), } }, @@ -443,7 +443,7 @@ impl OpenCADStudio { .add_filter("DWG Files", &["dwg"]) .save_file() .await - .map(|h| h.path().to_path_buf()); + .map(|h| crate::sys::handle_path(&h)); (name, path) }, |(name, path)| Message::WblockSaveResult(name, path), @@ -497,7 +497,7 @@ impl OpenCADStudio { .add_filter("All Files", &["*"]) .save_file() .await - .map(|h| h.path().to_path_buf()); + .map(|h| crate::sys::handle_path(&h)); (csv_clone, path) }, |(csv, path)| Message::DataExtractionSaveResult(csv, path), @@ -540,7 +540,7 @@ impl OpenCADStudio { .add_filter("All Files", &["*"]) .save_file() .await - .map(|h| h.path().to_path_buf()) + .map(|h| crate::sys::handle_path(&h)) }, Message::StlExportPath, ) @@ -594,7 +594,7 @@ impl OpenCADStudio { .add_filter("All Files", &["*"]) .save_file() .await - .map(|h| h.path().to_path_buf()) + .map(|h| crate::sys::handle_path(&h)) }, Message::StepExportPath, ) @@ -637,7 +637,7 @@ impl OpenCADStudio { .add_filter("All Files", &["*"]) .pick_file() .await - .map(|h| h.path().to_path_buf()) + .map(|h| crate::sys::handle_path(&h)) }, Message::ObjImportPath, ), @@ -5730,7 +5730,7 @@ impl OpenCADStudio { } } Message::UpdateNoticeOpenRelease => { - let _ = open::that(crate::update_check::RELEASES_PAGE); + crate::sys::open_url(crate::update_check::RELEASES_PAGE); if let Some(id) = self.update_notice_window.take() { window::close(id) } else { @@ -6313,7 +6313,7 @@ impl OpenCADStudio { .add_filter("All Files", &["*"]) .save_file() .await - .map(|h| h.path().to_path_buf()) + .map(|h| crate::sys::handle_path(&h)) }, Message::PlotStylePanelSavePath, ) diff --git a/src/io/mod.rs b/src/io/mod.rs index 22b277525..24a1fd0f9 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -40,7 +40,7 @@ pub async fn pick_open_path() -> Option<(PathBuf, u64)> { .add_filter("All Files", &["*"]) .pick_file() .await?; - let path = handle.path().to_path_buf(); + let path = crate::sys::handle_path(&handle); let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); Some((path, size)) } @@ -250,7 +250,7 @@ pub async fn pick_plot_style() -> Option { .add_filter("All Files", &["*"]) .pick_file() .await?; - plot_style::PlotStyleTable::load(handle.path()).ok() + plot_style::PlotStyleTable::load(&crate::sys::handle_path(&handle)).ok() } // ── Image file picker ───────────────────────────────────────────────────── @@ -267,7 +267,7 @@ pub async fn pick_image_file() -> Result<(PathBuf, u32, u32), String> { .pick_file() .await .ok_or_else(|| "Cancelled".to_string())?; - let path = handle.path().to_path_buf(); + let path = crate::sys::handle_path(&handle); let img = image::open(&path).map_err(|e| e.to_string())?; let (w, h) = image::GenericImageView::dimensions(&img); Ok((path, w, h)) diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 93b457602..d1dcffef7 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -62,7 +62,7 @@ pub async fn pick_pdf_path_owned(stem: String) -> Option { .add_filter("All Files", &["*"]) .save_file() .await - .map(|h| h.path().to_path_buf()) + .map(|h| crate::sys::handle_path(&h)) } // ── PDF builder ─────────────────────────────────────────────────────────── diff --git a/src/lib.rs b/src/lib.rs index 4f3d75c66..ccb62f5dc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,4 +11,5 @@ pub mod patterns; pub mod scene; pub mod snap; pub mod ui; +pub mod sys; pub mod update_check; diff --git a/src/main.rs b/src/main.rs index d58a6149d..0a3427e90 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ mod patterns; mod scene; mod snap; mod ui; +mod sys; mod update_check; fn main() -> iced::Result { diff --git a/src/scene/convert/mod.rs b/src/scene/convert/mod.rs index 306bb3b6f..71c96cf35 100644 --- a/src/scene/convert/mod.rs +++ b/src/scene/convert/mod.rs @@ -1,5 +1,21 @@ pub mod acad_to_truck; +#[cfg(feature = "solid3d")] pub mod acis_to_truck; +/// Without `solid3d` (e.g. wasm) ACIS/SAT meshing is unavailable; the entry +/// point stays so callers compile, returning no mesh. +#[cfg(not(feature = "solid3d"))] +pub mod acis_to_truck { + use crate::scene::model::mesh_model::MeshLodSet; + use acadrust::entities::acis::SatDocument; + pub fn tessellate_sat_truck( + _sat: &SatDocument, + _name: String, + _color: [f32; 4], + _facet_res: f64, + ) -> Option { + None + } +} pub mod truck_tess; pub mod tessellate; pub mod tess_util; diff --git a/src/scene/convert/truck_tess.rs b/src/scene/convert/truck_tess.rs index 6eeb06c25..0757c6da8 100644 --- a/src/scene/convert/truck_tess.rs +++ b/src/scene/convert/truck_tess.rs @@ -8,9 +8,11 @@ // The caller (acad_to_truck / tessellate) decides which function to call based on // the truck topology level of the object. +#[cfg(feature = "solid3d")] use truck_meshalgo::tessellation::{MeshableShape, MeshedShape}; use truck_modeling::base::{BoundedCurve, ParameterDivision1D}; use truck_modeling::{Edge, Shell, Solid, Vertex, Wire}; +#[cfg(feature = "solid3d")] use truck_polymesh::PolygonMesh; @@ -140,21 +142,45 @@ pub fn tessellate_wire(w: &Wire, offset: [f64; 3]) -> TruckTessResult { // ── Shell ───────────────────────────────────────────────────────────────── #[allow(dead_code)] +#[cfg(feature = "solid3d")] pub fn tessellate_shell(s: &Shell, offset: [f64; 3]) -> TruckTessResult { let meshed = s.triangulation(MESH_TOL); polygon_to_result(meshed.to_polygon(), offset) } +/// Without `solid3d` (e.g. wasm) there is no mesher; a shell yields no mesh. +#[cfg(not(feature = "solid3d"))] +pub fn tessellate_shell(_s: &Shell, _offset: [f64; 3]) -> TruckTessResult { + TruckTessResult::Mesh { + verts: Vec::new(), + normals: Vec::new(), + indices: Vec::new(), + } +} + // ── Solid ───────────────────────────────────────────────────────────────── #[allow(dead_code)] +#[cfg(feature = "solid3d")] pub fn tessellate_solid(s: &Solid, offset: [f64; 3]) -> TruckTessResult { let meshed = s.triangulation(MESH_TOL); polygon_to_result(meshed.to_polygon(), offset) } +/// Without `solid3d` (e.g. wasm) there is no mesher; a solid yields no mesh. +#[allow(dead_code)] +#[cfg(not(feature = "solid3d"))] +pub fn tessellate_solid(_s: &Solid, _offset: [f64; 3]) -> TruckTessResult { + TruckTessResult::Mesh { + verts: Vec::new(), + normals: Vec::new(), + indices: Vec::new(), + } +} + // ── Internal ───────────────────────────────────────────────────────────── +#[cfg(feature = "solid3d")] fn polygon_to_result(mesh: PolygonMesh, offset: [f64; 3]) -> TruckTessResult { let verts: Vec<[f32; 3]> = mesh .positions() diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index 1e173c07d..6be1d4535 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -130,10 +130,12 @@ pub enum Bool { Intersect, } +#[cfg(feature = "solid3d")] const BOOL_TOL: f64 = 0.05; /// Combine two solids. `Subtract` removes `b` from `a`. Returns `None` when the /// operation fails (e.g. the solids don't actually overlap). +#[cfg(feature = "solid3d")] pub fn boolean(op: Bool, a: &Solid, b: &Solid) -> Option { match op { Bool::Union => truck_shapeops::or(a, b, BOOL_TOL), @@ -146,6 +148,12 @@ pub fn boolean(op: Bool, a: &Solid, b: &Solid) -> Option { } } +/// Without `solid3d` (e.g. wasm) there is no boolean kernel. +#[cfg(not(feature = "solid3d"))] +pub fn boolean(_op: Bool, _a: &Solid, _b: &Solid) -> Option { + None +} + // ── Tessellation ──────────────────────────────────────────────────────────── /// Tessellate a truck `Solid` into a single-LOD `MeshLodSet` (world-space, diff --git a/src/sys.rs b/src/sys.rs new file mode 100644 index 000000000..48149bede --- /dev/null +++ b/src/sys.rs @@ -0,0 +1,31 @@ +// Small platform shims for things the desktop build does natively but the web +// (wasm) build must handle differently or skip. + +/// Open a URL in the user's browser. On the desktop this launches the default +/// handler; on the web the app already *is* in a browser, so for now this is a +/// no-op (a real implementation would call `window.open`). +#[cfg(not(target_arch = "wasm32"))] +pub fn open_url(url: &str) { + let _ = open::that(url); +} + +#[cfg(target_arch = "wasm32")] +pub fn open_url(_url: &str) { + // TODO(web): web_sys::window().open_with_url(_url) +} + +/// Turn an `rfd` file handle into a `PathBuf` the rest of the app keys on. +/// +/// Desktop returns the real filesystem path. The browser has no path, so we +/// synthesize one from the file name — enough for the app to compile and track +/// the document name; actual byte I/O on the web reads the handle directly +/// (a follow-up). +#[cfg(not(target_arch = "wasm32"))] +pub fn handle_path(h: &rfd::FileHandle) -> std::path::PathBuf { + h.path().to_path_buf() +} + +#[cfg(target_arch = "wasm32")] +pub fn handle_path(h: &rfd::FileHandle) -> std::path::PathBuf { + std::path::PathBuf::from(h.file_name()) +} diff --git a/src/update_check.rs b/src/update_check.rs index 0966784c3..e482f1a90 100644 --- a/src/update_check.rs +++ b/src/update_check.rs @@ -28,6 +28,7 @@ pub struct UpdateInfo { pub body: String, } +#[cfg(not(target_arch = "wasm32"))] pub async fn check_for_update() -> Option { std::thread::spawn(fetch_latest_if_outdated) .join() @@ -35,6 +36,14 @@ pub async fn check_for_update() -> Option { .flatten() } +/// On wasm there is no background thread or blocking HTTP client; the web build +/// skips the self-update check (the page is always served fresh). +#[cfg(target_arch = "wasm32")] +pub async fn check_for_update() -> Option { + None +} + +#[cfg(not(target_arch = "wasm32"))] fn fetch_latest_if_outdated() -> Option { let agent: ureq::Agent = ureq::Agent::config_builder() .timeout_global(Some(std::time::Duration::from_secs(5)))