From 6996343e8456e31d8008ccab9c7e381e14f27ed1 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 16 Jun 2026 18:08:57 +0300 Subject: [PATCH] feat(wasm): open files in the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire up file open for the web build: - `io::pick_and_load_web`: browser file picker → read bytes → parse via `load_bytes` → build caches, returning the same payload as the native loader so it feeds the shared `Message::FileOpened` handler. - `Message::OpenFile` cfg-dispatches: native picks a path then loads on a worker thread; web picks + parses in one async step. The web branch also sets `opening` (the handler drops results when no open is in progress). - Disable the cosmic-text shaping / fallback paths on wasm: the web has no system fonts, so cosmic-text panics with "no default font found". LFF stroke fonts (embedded) still render; LFF-missing glyphs are skipped. Native build + 75 tests unchanged. Hatches still don't draw on the web. Co-Authored-By: Claude Opus 4.8 --- src/app/update.rs | 24 +++++++++++++++++++++++- src/io/mod.rs | 25 +++++++++++++++++++++++++ src/scene/text/ttf_glyph.rs | 21 +++++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/app/update.rs b/src/app/update.rs index f3e695304..fa205f479 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -148,7 +148,29 @@ impl OpenCADStudio { } Message::OpenFile => { - Task::perform(crate::io::pick_open_path(), Message::OpenPathPicked) + // Native: pick a path, then load on a worker thread. Web: the + // browser hands back bytes, so pick + parse in one step and feed + // the shared `FileOpened` handler directly. + #[cfg(not(target_arch = "wasm32"))] + { + Task::perform(crate::io::pick_open_path(), Message::OpenPathPicked) + } + #[cfg(target_arch = "wasm32")] + { + // `FileOpened` only installs the result when an open is in + // progress, so mark one. The browser picker + parse happen + // inside `pick_and_load_web`; the real name is unknown until + // then, so show a generic label meanwhile. + self.opening = Some(super::OpenProgress { + name: "Opening…".into(), + size_bytes: 0, + phase: std::sync::Arc::new(std::sync::atomic::AtomicU8::new( + super::OPEN_PHASE_READING, + )), + started: Instant::now(), + }); + Task::perform(crate::io::pick_and_load_web(), Message::FileOpened) + } } Message::OpenPathPicked(None) => Task::none(), diff --git a/src/io/mod.rs b/src/io/mod.rs index 868cf29e8..92ca9c6e8 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -86,6 +86,31 @@ pub async fn open_path_with_phase( Ok((name, path, doc, caches)) } +/// Web file open: show the browser picker, read the chosen file's bytes, parse +/// it, and build the derived caches — producing the same payload as the native +/// `open_path_with_phase` so it can feed the existing `Message::FileOpened` +/// handler. There is no filesystem path on the web, so a name-only `PathBuf` +/// stands in for the document path. +#[cfg(target_arch = "wasm32")] +pub async fn pick_and_load_web( +) -> Result<(String, PathBuf, CadDocument, DerivedCaches), String> { + let handle = rfd::AsyncFileDialog::new() + .set_title("Open CAD file") + .add_filter("CAD Files", &["dwg", "dxf", "DWG", "DXF"]) + .add_filter("All Files", &["*"]) + .pick_file() + .await + .ok_or_else(|| "Cancelled".to_string())?; + let name = handle.file_name(); + let bytes = handle.read().await; + let mut doc = load_bytes(&name, bytes)?; + let dropped = purge_corrupt_entities(&mut doc); + let mut caches = crate::scene::build_derived_caches(&doc); + caches.corrupt_dropped = dropped; + let path = PathBuf::from(&name); + Ok((name, path, doc, caches)) +} + /// Parse a CAD document from in-memory bytes, choosing the format from /// `name`'s extension. Used by the web build, where files arrive as bytes from /// a browser file picker (no filesystem path). Shares the post-load fixups with diff --git a/src/scene/text/ttf_glyph.rs b/src/scene/text/ttf_glyph.rs index a3ff749c7..567949a01 100644 --- a/src/scene/text/ttf_glyph.rs +++ b/src/scene/text/ttf_glyph.rs @@ -209,6 +209,11 @@ const SHAPE_FS: f32 = 1000.0; /// Shared cosmic-text layout engine, loaded once with the system fonts. Held /// behind a mutex because `set_text`/`shape` need `&mut FontSystem`. +/// +/// Native only: on the web there are no system fonts, so cosmic-text would +/// panic with "no default font found" — the shaping / fallback paths are +/// disabled there instead (LFF stroke fonts still render). +#[cfg(not(target_arch = "wasm32"))] fn font_system() -> &'static Mutex { static FS: OnceLock> = OnceLock::new(); FS.get_or_init(|| Mutex::new(cosmic_text::FontSystem::new())) @@ -258,6 +263,14 @@ pub fn fallback_glyph(ch: char) -> Option> { built } +/// Web: no system fonts → no cosmic-text fallback (LFF-missing glyphs simply +/// do not render). +#[cfg(target_arch = "wasm32")] +fn build_fallback(_ch: char) -> Option> { + None +} + +#[cfg(not(target_arch = "wasm32"))] fn build_fallback(ch: char) -> Option> { use cosmic_text::{Attrs, Buffer, Metrics, Shaping}; let mut fs = font_system().lock().unwrap(); @@ -292,6 +305,14 @@ fn build_fallback(ch: char) -> Option> { None } +/// Web: no system fonts → no shaping (TTF faces don't occur on the web anyway, +/// since system-font discovery is empty). +#[cfg(target_arch = "wasm32")] +fn build_shaped(_family: &str, _text: &str) -> Option { + None +} + +#[cfg(not(target_arch = "wasm32"))] fn build_shaped(family: &str, text: &str) -> Option { use cosmic_text::{Attrs, Buffer, Family, Metrics, Shaping};