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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-16 13:01:19 +03:00
1 parent 3666c7fa69
commit b2e20c95fe
12 files changed
+123 -20

No files matched your search

+16 -5
View File
@@ -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"] }
+3 -3
View File
@@ -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...");
}
+8 -8
View File
@@ -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,
)
+3 -3
View File
@@ -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<plot_style::PlotStyleTable> {
.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))
+1 -1
View File
@@ -62,7 +62,7 @@ pub async fn pick_pdf_path_owned(stem: String) -> Option<std::path::PathBuf> {
.add_filter("All Files", &["*"])
.save_file()
.await
.map(|h| h.path().to_path_buf())
.map(|h| crate::sys::handle_path(&h))
}
// ── PDF builder ───────────────────────────────────────────────────────────
+1
View File
@@ -11,4 +11,5 @@ pub mod patterns;
pub mod scene;
pub mod snap;
pub mod ui;
pub mod sys;
pub mod update_check;
+1
View File
@@ -15,6 +15,7 @@ mod patterns;
mod scene;
mod snap;
mod ui;
mod sys;
mod update_check;
fn main() -> iced::Result {
+16
View File
@@ -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<MeshLodSet> {
None
}
}
pub mod truck_tess;
pub mod tessellate;
pub mod tess_util;
+26
View File
@@ -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()
+8
View File
@@ -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<Solid> {
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<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 ────────────────────────────────────────────────────────────
/// Tessellate a truck `Solid` into a single-LOD `MeshLodSet` (world-space,
+31
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())
}
+9
View File
@@ -28,6 +28,7 @@ pub struct UpdateInfo {
pub body: String,
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn check_for_update() -> Option<UpdateInfo> {
std::thread::spawn(fetch_latest_if_outdated)
.join()
@@ -35,6 +36,14 @@ pub async fn check_for_update() -> Option<UpdateInfo> {
.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> {
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(std::time::Duration::from_secs(5)))