feat(wasm): compile the library for wasm32 (issue #45 groundwork)

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 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-16 13:01:19 +03:00
commit b2e20c95fe
12 changed files with 123 additions and 20 deletions

View file

@ -12,6 +12,15 @@ members = ["crates/ocs_plugin_api"]
[target.'cfg(windows)'.build-dependencies] [target.'cfg(windows)'.build-dependencies]
winresource = "0.1" 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] [dependencies]
# Stable, dependency-free add-on contract (manifest + ribbon/CadModule types). # Stable, dependency-free add-on contract (manifest + ribbon/CadModule types).
# Plugin authors target this crate's semver, not OpenCADStudio internals. # 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). # the .app crashes on launch on machines without that exact library (#56).
# Forcing the `static` feature builds liblzma from the bundled source, making # Forcing the `static` feature builds liblzma from the bundled source, making
# the binary self-contained on every platform. # 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"] } iced = { version = "0.14", features = ["image", "svg", "advanced", "canvas", "smol"] }
bytemuck = { version = "1.25", features = ["derive"] } bytemuck = { version = "1.25", features = ["derive"] }
glam = { version = "0.33", features = ["bytemuck"] } glam = { version = "0.33", features = ["bytemuck"] }
truck-modeling = "0.6" 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" truck-polymesh = "0.6"
rfd = "0.17" rfd = "0.17"
acadrust = "0.3.4" acadrust = "0.3.4"
open = "5"
printpdf = "0.9.1" printpdf = "0.9.1"
flate2 = "1" flate2 = "1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
rayon = "1" rayon = "1"
ureq = { version = "3", default-features = false, features = ["rustls"] }
inventory = "0.3" 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, # Fast non-cryptographic hasher. Handle/u64 keys dominate the hot block,
# hatch, draw-depth and wire caches; the default SipHash is overkill there. # hatch, draw-depth and wire caches; the default SipHash is overkill there.
rustc-hash = "2" 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 # Z-decode fix, and CANNOSCALE/CANNOSCALEVALUE header support on top of
# upstream. Revert to upstream once those PRs land. # upstream. Revert to upstream once those PRs land.
acadrust = { git = "https://github.com/HakanSeven12/acadrust", branch = "main" } 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"] }

View file

@ -2489,7 +2489,7 @@ impl OpenCADStudio {
} }
"DONATE" => { "DONATE" => {
let _ = open::that("https://patreon.com/HakanSeven12"); crate::sys::open_url("https://patreon.com/HakanSeven12");
self.command_line.push_info("Opening Patreon page..."); self.command_line.push_info("Opening Patreon page...");
} }
@ -2669,7 +2669,7 @@ impl OpenCADStudio {
} }
"REPORT" => { "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..."); self.command_line.push_info("Opening feedback page...");
} }
@ -2682,7 +2682,7 @@ impl OpenCADStudio {
} }
"CHANGELOG" => { "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..."); self.command_line.push_info("Opening release notes...");
} }

View file

@ -408,7 +408,7 @@ impl OpenCADStudio {
.pick_file() .pick_file()
.await; .await;
match handle { match handle {
Some(h) => Ok(h.path().to_path_buf()), Some(h) => Ok(crate::sys::handle_path(&h)),
None => Err("Cancelled".to_string()), None => Err("Cancelled".to_string()),
} }
}, },
@ -443,7 +443,7 @@ impl OpenCADStudio {
.add_filter("DWG Files", &["dwg"]) .add_filter("DWG Files", &["dwg"])
.save_file() .save_file()
.await .await
.map(|h| h.path().to_path_buf()); .map(|h| crate::sys::handle_path(&h));
(name, path) (name, path)
}, },
|(name, path)| Message::WblockSaveResult(name, path), |(name, path)| Message::WblockSaveResult(name, path),
@ -497,7 +497,7 @@ impl OpenCADStudio {
.add_filter("All Files", &["*"]) .add_filter("All Files", &["*"])
.save_file() .save_file()
.await .await
.map(|h| h.path().to_path_buf()); .map(|h| crate::sys::handle_path(&h));
(csv_clone, path) (csv_clone, path)
}, },
|(csv, path)| Message::DataExtractionSaveResult(csv, path), |(csv, path)| Message::DataExtractionSaveResult(csv, path),
@ -540,7 +540,7 @@ impl OpenCADStudio {
.add_filter("All Files", &["*"]) .add_filter("All Files", &["*"])
.save_file() .save_file()
.await .await
.map(|h| h.path().to_path_buf()) .map(|h| crate::sys::handle_path(&h))
}, },
Message::StlExportPath, Message::StlExportPath,
) )
@ -594,7 +594,7 @@ impl OpenCADStudio {
.add_filter("All Files", &["*"]) .add_filter("All Files", &["*"])
.save_file() .save_file()
.await .await
.map(|h| h.path().to_path_buf()) .map(|h| crate::sys::handle_path(&h))
}, },
Message::StepExportPath, Message::StepExportPath,
) )
@ -637,7 +637,7 @@ impl OpenCADStudio {
.add_filter("All Files", &["*"]) .add_filter("All Files", &["*"])
.pick_file() .pick_file()
.await .await
.map(|h| h.path().to_path_buf()) .map(|h| crate::sys::handle_path(&h))
}, },
Message::ObjImportPath, Message::ObjImportPath,
), ),
@ -5730,7 +5730,7 @@ impl OpenCADStudio {
} }
} }
Message::UpdateNoticeOpenRelease => { 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() { if let Some(id) = self.update_notice_window.take() {
window::close(id) window::close(id)
} else { } else {
@ -6313,7 +6313,7 @@ impl OpenCADStudio {
.add_filter("All Files", &["*"]) .add_filter("All Files", &["*"])
.save_file() .save_file()
.await .await
.map(|h| h.path().to_path_buf()) .map(|h| crate::sys::handle_path(&h))
}, },
Message::PlotStylePanelSavePath, Message::PlotStylePanelSavePath,
) )

View file

@ -40,7 +40,7 @@ pub async fn pick_open_path() -> Option<(PathBuf, u64)> {
.add_filter("All Files", &["*"]) .add_filter("All Files", &["*"])
.pick_file() .pick_file()
.await?; .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); let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
Some((path, size)) Some((path, size))
} }
@ -250,7 +250,7 @@ pub async fn pick_plot_style() -> Option<plot_style::PlotStyleTable> {
.add_filter("All Files", &["*"]) .add_filter("All Files", &["*"])
.pick_file() .pick_file()
.await?; .await?;
plot_style::PlotStyleTable::load(handle.path()).ok() plot_style::PlotStyleTable::load(&crate::sys::handle_path(&handle)).ok()
} }
// ── Image file picker ───────────────────────────────────────────────────── // ── Image file picker ─────────────────────────────────────────────────────
@ -267,7 +267,7 @@ pub async fn pick_image_file() -> Result<(PathBuf, u32, u32), String> {
.pick_file() .pick_file()
.await .await
.ok_or_else(|| "Cancelled".to_string())?; .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 img = image::open(&path).map_err(|e| e.to_string())?;
let (w, h) = image::GenericImageView::dimensions(&img); let (w, h) = image::GenericImageView::dimensions(&img);
Ok((path, w, h)) Ok((path, w, h))

View file

@ -62,7 +62,7 @@ pub async fn pick_pdf_path_owned(stem: String) -> Option<std::path::PathBuf> {
.add_filter("All Files", &["*"]) .add_filter("All Files", &["*"])
.save_file() .save_file()
.await .await
.map(|h| h.path().to_path_buf()) .map(|h| crate::sys::handle_path(&h))
} }
// ── PDF builder ─────────────────────────────────────────────────────────── // ── PDF builder ───────────────────────────────────────────────────────────

View file

@ -11,4 +11,5 @@ pub mod patterns;
pub mod scene; pub mod scene;
pub mod snap; pub mod snap;
pub mod ui; pub mod ui;
pub mod sys;
pub mod update_check; pub mod update_check;

View file

@ -15,6 +15,7 @@ mod patterns;
mod scene; mod scene;
mod snap; mod snap;
mod ui; mod ui;
mod sys;
mod update_check; mod update_check;
fn main() -> iced::Result { fn main() -> iced::Result {

View file

@ -1,5 +1,21 @@
pub mod acad_to_truck; pub mod acad_to_truck;
#[cfg(feature = "solid3d")]
pub mod acis_to_truck; 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<MeshLodSet> {
None
}
}
pub mod truck_tess; pub mod truck_tess;
pub mod tessellate; pub mod tessellate;
pub mod tess_util; pub mod tess_util;

View file

@ -8,9 +8,11 @@
// The caller (acad_to_truck / tessellate) decides which function to call based on // The caller (acad_to_truck / tessellate) decides which function to call based on
// the truck topology level of the object. // the truck topology level of the object.
#[cfg(feature = "solid3d")]
use truck_meshalgo::tessellation::{MeshableShape, MeshedShape}; use truck_meshalgo::tessellation::{MeshableShape, MeshedShape};
use truck_modeling::base::{BoundedCurve, ParameterDivision1D}; use truck_modeling::base::{BoundedCurve, ParameterDivision1D};
use truck_modeling::{Edge, Shell, Solid, Vertex, Wire}; use truck_modeling::{Edge, Shell, Solid, Vertex, Wire};
#[cfg(feature = "solid3d")]
use truck_polymesh::PolygonMesh; use truck_polymesh::PolygonMesh;
@ -140,21 +142,45 @@ pub fn tessellate_wire(w: &Wire, offset: [f64; 3]) -> TruckTessResult {
// ── Shell ───────────────────────────────────────────────────────────────── // ── Shell ─────────────────────────────────────────────────────────────────
#[allow(dead_code)] #[allow(dead_code)]
#[cfg(feature = "solid3d")]
pub fn tessellate_shell(s: &Shell, offset: [f64; 3]) -> TruckTessResult { pub fn tessellate_shell(s: &Shell, offset: [f64; 3]) -> TruckTessResult {
let meshed = s.triangulation(MESH_TOL); let meshed = s.triangulation(MESH_TOL);
polygon_to_result(meshed.to_polygon(), offset) 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 ───────────────────────────────────────────────────────────────── // ── Solid ─────────────────────────────────────────────────────────────────
#[allow(dead_code)] #[allow(dead_code)]
#[cfg(feature = "solid3d")]
pub fn tessellate_solid(s: &Solid, offset: [f64; 3]) -> TruckTessResult { pub fn tessellate_solid(s: &Solid, offset: [f64; 3]) -> TruckTessResult {
let meshed = s.triangulation(MESH_TOL); let meshed = s.triangulation(MESH_TOL);
polygon_to_result(meshed.to_polygon(), offset) 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 ───────────────────────────────────────────────────────────── // ── Internal ─────────────────────────────────────────────────────────────
#[cfg(feature = "solid3d")]
fn polygon_to_result(mesh: PolygonMesh, offset: [f64; 3]) -> TruckTessResult { fn polygon_to_result(mesh: PolygonMesh, offset: [f64; 3]) -> TruckTessResult {
let verts: Vec<[f32; 3]> = mesh let verts: Vec<[f32; 3]> = mesh
.positions() .positions()

View file

@ -130,10 +130,12 @@ pub enum Bool {
Intersect, Intersect,
} }
#[cfg(feature = "solid3d")]
const BOOL_TOL: f64 = 0.05; const BOOL_TOL: f64 = 0.05;
/// Combine two solids. `Subtract` removes `b` from `a`. Returns `None` when the /// Combine two solids. `Subtract` removes `b` from `a`. Returns `None` when the
/// operation fails (e.g. the solids don't actually overlap). /// operation fails (e.g. the solids don't actually overlap).
#[cfg(feature = "solid3d")]
pub fn boolean(op: Bool, a: &Solid, b: &Solid) -> Option<Solid> { pub fn boolean(op: Bool, a: &Solid, b: &Solid) -> Option<Solid> {
match op { match op {
Bool::Union => truck_shapeops::or(a, b, BOOL_TOL), Bool::Union => truck_shapeops::or(a, b, BOOL_TOL),
@ -146,6 +148,12 @@ pub fn boolean(op: Bool, a: &Solid, b: &Solid) -> Option<Solid> {
} }
} }
/// Without `solid3d` (e.g. wasm) there is no boolean kernel.
#[cfg(not(feature = "solid3d"))]
pub fn boolean(_op: Bool, _a: &Solid, _b: &Solid) -> Option<Solid> {
None
}
// ── Tessellation ──────────────────────────────────────────────────────────── // ── Tessellation ────────────────────────────────────────────────────────────
/// Tessellate a truck `Solid` into a single-LOD `MeshLodSet` (world-space, /// Tessellate a truck `Solid` into a single-LOD `MeshLodSet` (world-space,

31
src/sys.rs Normal file
View file

@ -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())
}

View file

@ -28,6 +28,7 @@ pub struct UpdateInfo {
pub body: String, pub body: String,
} }
#[cfg(not(target_arch = "wasm32"))]
pub async fn check_for_update() -> Option<UpdateInfo> { pub async fn check_for_update() -> Option<UpdateInfo> {
std::thread::spawn(fetch_latest_if_outdated) std::thread::spawn(fetch_latest_if_outdated)
.join() .join()
@ -35,6 +36,14 @@ pub async fn check_for_update() -> Option<UpdateInfo> {
.flatten() .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<UpdateInfo> {
None
}
#[cfg(not(target_arch = "wasm32"))]
fn fetch_latest_if_outdated() -> Option<UpdateInfo> { fn fetch_latest_if_outdated() -> Option<UpdateInfo> {
let agent: ureq::Agent = ureq::Agent::config_builder() let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(std::time::Duration::from_secs(5))) .timeout_global(Some(std::time::Duration::from_secs(5)))