web: enable STL/STEP export and OBJ import, drag-and-drop open

STLOUT/STEPOUT wrote via std::fs and IMPORTOBJ read via std::fs, all
of which fail on wasm32. The export pickers now mirror the PDF path
(pick_*_owned returns a name-only PathBuf; the browser download names
the file) and the write tail swaps fs::write for sys::download_bytes.
OBJ import reads the picked file's bytes directly, like the web open
picker.

Opening by drag-and-drop was dead on the web build: winit never emits
FileDropped there. A wasm-gated io::web_drop module installs DOM
dragover/drop listeners, filters .dwg/.dxf, reads the File bytes
async, and queues them; the existing 50 ms PollWebControl tick drains
the queue into the same open path the picker uses (progress overlay,
recovery, OPFS recent cache).

Also floors the model-space plot sheet: extents smaller than 10 mm
(or the 0x0 paper a fresh unsaved drawing carries) now fall back to
the standard sheet instead of producing a ~3 pt page.

(cherry picked from commit ad60be11ed)
This commit is contained in:
Afonsop authored and Hakan Seven committed 2026-10-12 12:02:04 +03:00
1 parent 112060e370
commit 6cb8c00fa0
8 files changed
+332 -36

No files matched your search

+4
View File
@@ -157,6 +157,10 @@ web-sys = { version = "=0.3.85", features = [
"HtmlCanvasElement",
"CanvasRenderingContext2d",
"ImageData",
"DragEvent",
"DataTransfer",
"FileList",
"Event",
] }
iced = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39", features = ["webgl", "fira-sans"] }
+9
View File
@@ -2231,6 +2231,10 @@ pub enum Message {
OpenPathPicked(Option<(PathBuf, u64)>),
/// A file was dragged from the desktop and dropped on the window (#344).
FileDropped(PathBuf),
/// Web only: a drawing dragged onto the page — the DOM drop listener has
/// already read the bytes; this opens through the web-picker flow (#344).
#[cfg(target_arch = "wasm32")]
WebFileDropped(String, Vec<u8>),
/// Web only: Ctrl+V captured by a focused text field, whose iced-internal
/// paste is inert there — read the async clipboard instead (#346).
WebFieldPaste,
@@ -4077,6 +4081,8 @@ pub enum Message {
/// Trigger OBJ import: show open-file dialog.
ObjImport,
/// Callback after the user picks (or cancels) the OBJ file path.
/// Web never constructs it — the browser handle's bytes are read directly.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
ObjImportPath(Option<std::path::PathBuf>),
ObjImportFinished(
u64,
@@ -5000,6 +5006,9 @@ impl Drop for OpenCADStudio {
/// unavailable for now (see issue #45). Native keeps the multi-window `run`.
#[cfg(target_arch = "wasm32")]
pub fn run_web() -> iced::Result {
// winit never emits DroppedFile on the web, so the document drag & drop
// listeners are installed here and feed the control poll (#344).
crate::io::web_drop::install();
iced::application(
OpenCADStudio::boot_web,
OpenCADStudio::update,
+22 -8
View File
@@ -4395,19 +4395,33 @@ impl OpenCADStudio {
}
// Model space keeps its established extents + 5% margin behaviour.
// A sheet edge below this floor (drawing with tiny extents, or the
// 0×0 paper a fresh unsaved drawing carries) cannot be previewed,
// printed, or measured meaningfully; the standard-sheet fallback
// keeps the output a real page instead of a speck.
const MIN_MODEL_SHEET_MM: f64 = 10.0;
let standard_sheet = {
let (w, h) = selected_sheet;
(w.min(h) >= MIN_MODEL_SHEET_MM).then_some((w, h))
};
let (page_w, page_h, offset_x, offset_y) =
if let Some((min, max)) = scene.model_space_extents() {
let margin = 1.05_f64;
let width = ((max.x - min.x) as f64 * margin).max(1.0);
let height = ((max.y - min.y) as f64 * margin).max(1.0);
let pad_x = (width - (max.x - min.x) as f64) * 0.5;
let pad_y = (height - (max.y - min.y) as f64) * 0.5;
(
width,
height,
-(min.x as f64) + pad_x,
-(min.y as f64) + pad_y,
)
if width < MIN_MODEL_SHEET_MM || height < MIN_MODEL_SHEET_MM {
let (w, h) = standard_sheet.unwrap_or((297.0, 210.0));
(w, h, -min.x as f64, -min.y as f64)
} else {
let pad_x = (width - (max.x - min.x) as f64) * 0.5;
let pad_y = (height - (max.y - min.y) as f64) * 0.5;
(
width,
height,
-(min.x as f64) + pad_x,
-(min.y as f64) + pad_y,
)
}
} else {
(297.0, 210.0, 0.0, 0.0)
};
+138 -28
View File
@@ -662,11 +662,25 @@ impl OpenCADStudio {
}
#[cfg(target_arch = "wasm32")]
Message::PollWebControl => {
if let Some(request) = super::control::web_request() {
let control = if let Some(request) = super::control::web_request() {
self.update(Message::ControlRequest(request))
} else {
Task::none()
}
};
// Dragged-in drawings ride the same poll (#344): start one
// only while the open slot is free — the same guard as a
// desktop drop — and leave the rest queued, so a multi-file
// drop opens sequentially without its own subscription.
let dropped = if self.opening.is_none()
&& self.active_modal != Some(super::ModalKind::Recovery)
{
crate::io::web_drop::take_dropped()
.map(|(name, bytes)| self.update(Message::WebFileDropped(name, bytes)))
.unwrap_or_else(Task::none)
} else {
Task::none()
};
Task::batch([control, dropped])
}
#[cfg(not(target_arch = "wasm32"))]
Message::PollWebControl => Task::none(),
@@ -1090,6 +1104,32 @@ impl OpenCADStudio {
self.drain_pending_open()
}
#[cfg(target_arch = "wasm32")]
Message::WebFileDropped(name, bytes) => {
// Bytes are already in hand (no picker round-trip), so this is
// `on_open_file`'s web branch with the real name and size on
// the overlay from the start.
let state = std::sync::Arc::new(crate::io::OpenProgressState::new(
crate::app::OPEN_PHASE_READING,
));
let open_id = self.next_open_id();
self.opening = Some(crate::app::OpenProgress {
id: open_id,
name: name.clone(),
source_path: Some(std::path::PathBuf::from(&name)),
size_bytes: bytes.len() as u64,
state: state.clone(),
started: Instant::now(),
recovery_error: None,
recovery_read_stats: None,
recovery_bytes: None,
});
Task::perform(
crate::io::web_drop::open_dropped(name, bytes, state),
move |outcome| Message::WebFileOpened(open_id, outcome),
)
}
#[cfg(target_arch = "wasm32")]
Message::WebFileOpened(open_id, mut outcome) => {
if self.opening.as_ref().map(|opening| opening.id) != Some(open_id) {
@@ -1615,19 +1655,37 @@ impl OpenCADStudio {
.push_error(crate::t!("STLOUT: no 3D mesh data in this drawing.").as_ref());
return Task::none();
}
Task::perform(
async {
crate::sys::file_dialog()
.set_title(crate::t!("Export STL").as_ref())
.set_file_name("export.stl")
.add_filter(crate::t!("STL Files").as_ref(), &["stl"])
.add_filter(crate::t!("All Files").as_ref(), &["*"])
.save_file()
.await
.map(|h| crate::sys::handle_path(&h))
},
Message::StlExportPath,
)
#[cfg(not(target_arch = "wasm32"))]
{
Task::perform(
async {
crate::sys::file_dialog()
.set_title(crate::t!("Export STL").as_ref())
.set_file_name("export.stl")
.add_filter(crate::t!("STL Files").as_ref(), &["stl"])
.add_filter(crate::t!("All Files").as_ref(), &["*"])
.save_file()
.await
.map(|h| crate::sys::handle_path(&h))
},
Message::StlExportPath,
)
}
// Web: rfd's save dialog shows no UI, so skip straight to a
// virtual path — the browser download supplies the file name.
#[cfg(target_arch = "wasm32")]
{
let stem = self.tabs[i]
.current_path
.as_deref()
.and_then(|p: &std::path::Path| p.file_stem())
.map(|s: &std::ffi::OsStr| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "drawing".into());
Task::perform(
crate::io::stl::pick_stl_path_owned(stem),
Message::StlExportPath,
)
}
}
Message::StlExportPath(Some(path)) => self.on_stl_export_path_some(path),
@@ -1655,19 +1713,37 @@ impl OpenCADStudio {
);
return Task::none();
}
Task::perform(
async {
crate::sys::file_dialog()
.set_title(crate::t!("Export STEP AP203").as_ref())
.set_file_name("export.step")
.add_filter(crate::t!("STEP Files").as_ref(), &["step", "stp"])
.add_filter(crate::t!("All Files").as_ref(), &["*"])
.save_file()
.await
.map(|h| crate::sys::handle_path(&h))
},
Message::StepExportPath,
)
#[cfg(not(target_arch = "wasm32"))]
{
Task::perform(
async {
crate::sys::file_dialog()
.set_title(crate::t!("Export STEP AP203").as_ref())
.set_file_name("export.step")
.add_filter(crate::t!("STEP Files").as_ref(), &["step", "stp"])
.add_filter(crate::t!("All Files").as_ref(), &["*"])
.save_file()
.await
.map(|h| crate::sys::handle_path(&h))
},
Message::StepExportPath,
)
}
// Web: rfd's save dialog shows no UI, so skip straight to a
// virtual path — the browser download supplies the file name.
#[cfg(target_arch = "wasm32")]
{
let stem = self.tabs[i]
.current_path
.as_deref()
.and_then(|p: &std::path::Path| p.file_stem())
.map(|s: &std::ffi::OsStr| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "drawing".into());
Task::perform(
crate::io::step::pick_step_path_owned(stem),
Message::StepExportPath,
)
}
}
Message::StepExportPath(Some(path)) => self.on_step_export_path_some(path),
@@ -1687,6 +1763,7 @@ impl OpenCADStudio {
}
// ── OBJ import ────────────────────────────────────────────────
#[cfg(not(target_arch = "wasm32"))]
Message::ObjImport => Task::perform(
async {
crate::sys::file_dialog()
@@ -1700,6 +1777,39 @@ impl OpenCADStudio {
Message::ObjImportPath,
),
// Web: the browser picker hands back a handle whose bytes must be
// read directly — the name-only `PathBuf` it maps to cannot be
// opened — so pick, read, and parse in one step and feed the
// shared finished handler. A cancelled picker stays silent.
#[cfg(target_arch = "wasm32")]
Message::ObjImport => {
let tab_id = self.tabs[self.active_tab].id;
Task::perform(
async move {
let handle = crate::sys::file_dialog()
.set_title(crate::t!("Import OBJ Mesh").as_ref())
.add_filter(crate::t!("Wavefront OBJ").as_ref(), &["obj", "OBJ"])
.add_filter(crate::t!("All Files").as_ref(), &["*"])
.pick_file()
.await?;
let path = crate::sys::handle_path(&handle);
let bytes = handle.read().await;
let result = crate::io::obj::parse_obj(
&String::from_utf8_lossy(&bytes),
[0.7, 0.7, 0.85, 1.0],
)
.ok_or_else(|| "no usable geometry in file".to_string());
Some((tab_id, path, result))
},
|picked| match picked {
Some((tab_id, path, result)) => {
Message::ObjImportFinished(tab_id, path, result)
}
None => Message::Noop,
},
)
}
Message::ObjImportPath(Some(path)) => self.on_obj_import_path_some(path),
Message::ObjImportPath(None) => Task::none(),
+2
View File
@@ -31,6 +31,8 @@ pub mod visual_styles;
pub(crate) mod web_recent;
#[cfg(target_arch = "wasm32")]
mod web_worker;
#[cfg(target_arch = "wasm32")]
pub(crate) mod web_drop;
pub mod windows_media;
pub mod xref;
pub mod xref_model;
+7
View File
@@ -252,6 +252,13 @@ pub fn build_step(meshes: &[&MeshModel]) -> Option<String> {
Some(file)
}
/// Web: no save dialog — the export continues with a virtual filename and
/// the generated bytes are delivered as a browser download.
#[cfg(target_arch = "wasm32")]
pub async fn pick_step_path_owned(stem: String) -> Option<std::path::PathBuf> {
Some(std::path::PathBuf::from(format!("{stem}.step")))
}
/// Returns an ISO 8601-like timestamp string for the STEP file header.
fn chrono_timestamp() -> String {
// Use seconds since Unix epoch for a simple timestamp without chrono dep.
+7
View File
@@ -96,6 +96,13 @@ pub fn build_stl(meshes: &[&MeshModel]) -> Option<Vec<u8>> {
Some(buf)
}
/// Web: no save dialog — the export continues with a virtual filename and
/// the generated bytes are delivered as a browser download.
#[cfg(target_arch = "wasm32")]
pub async fn pick_stl_path_owned(stem: String) -> Option<std::path::PathBuf> {
Some(std::path::PathBuf::from(format!("{stem}.stl")))
}
#[cfg(test)]
mod tests {
use super::build_stl;
+143
View File
@@ -0,0 +1,143 @@
//! Web drag & drop file opening (#344).
//!
//! winit's web backend never emits `DroppedFile`, so the page itself listens
//! for DOM `dragover`/`drop` events, reads each dropped drawing's bytes, and
//! queues them for the 50 ms control poll to open through the web-picker flow.
use std::collections::VecDeque;
use std::sync::{Mutex, OnceLock};
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture;
/// A stray multi-drop must not grow the queue without bound; extras are
/// rejected with a console warning instead of being silently dropped.
const QUEUE_CAP: usize = 8;
static DROPPED: Mutex<VecDeque<(String, Vec<u8>)>> = Mutex::new(VecDeque::new());
static INSTALLED: OnceLock<()> = OnceLock::new();
fn warn(line: &str) {
web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(line));
}
fn enqueue(name: String, bytes: Vec<u8>) {
let mut queue = DROPPED.lock().unwrap();
if queue.len() >= QUEUE_CAP {
drop(queue);
warn(&format!(
"ocs: dropped-file queue full; ignoring \"{name}\""
));
return;
}
queue.push_back((name, bytes));
}
/// JS-callable entry (same shape as `ocs_control_submit`) for hosts that
/// forward drops themselves.
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn ocs_web_drop_file(name: String, bytes: js_sys::Uint8Array) {
enqueue(name, bytes.to_vec());
}
/// Pop the oldest dropped file that the poll has not started opening yet.
pub fn take_dropped() -> Option<(String, Vec<u8>)> {
DROPPED.lock().unwrap().pop_front()
}
/// Feed already-read bytes through the same loader tail the web picker uses
/// (including the OPFS recent-copy payload), so `WebFileOpened` cannot tell
/// the two doors apart.
pub async fn open_dropped(
name: String,
bytes: Vec<u8>,
progress: std::sync::Arc<crate::io::OpenProgressState>,
) -> crate::io::WebOpenOutcome {
progress.set(crate::app::OPEN_PHASE_READING, 500, 1, 2);
let bytes: std::sync::Arc<[u8]> = std::sync::Arc::from(bytes);
let size_bytes = bytes.len() as u64;
let result = super::load_web_bytes(&name, &bytes, progress, false, "", None).await;
let keep_for_recovery = result
.as_ref()
.err()
.is_some_and(|error| error.recovery_available);
let cache_bytes = result.is_ok().then(|| std::sync::Arc::clone(&bytes));
crate::io::WebOpenOutcome {
name,
size_bytes,
result,
recovery_bytes: keep_for_recovery.then(|| std::sync::Arc::clone(&bytes)),
cache_bytes,
record_recent: false,
}
}
/// Register the document-level `dragover`/`drop` listeners once. `dragover`
/// MUST preventDefault or the browser navigates to the dropped file instead
/// of delivering it to the page.
pub fn install() {
if INSTALLED.set(()).is_err() {
return;
}
let Some(document) = web_sys::window().and_then(|window| window.document()) else {
return;
};
let on_dragover = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::Event)>::new(
move |event: web_sys::Event| {
event.prevent_default();
},
);
let _ =
document.add_event_listener_with_callback("dragover", on_dragover.as_ref().unchecked_ref());
on_dragover.forget();
let on_drop = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::Event)>::new(
move |event: web_sys::Event| {
event.prevent_default();
event.stop_propagation();
let Some(drag) = event.dyn_ref::<web_sys::DragEvent>() else {
return;
};
let Some(files) = drag.data_transfer().and_then(|transfer| transfer.files()) else {
return;
};
for index in 0..files.length() {
let Some(file) = files.get(index) else {
continue;
};
let name = file.name();
// A drop has no picker filter: accept the formats the web Open
// dialog accepts; anything else is logged, never parsed.
if !is_drawing_name(&name) {
warn(&format!(
"ocs: ignored dropped file \"{name}\" (not .dwg/.dxf)"
));
continue;
}
wasm_bindgen_futures::spawn_local(async move {
match read_file(&file).await {
Ok(bytes) => enqueue(name, bytes),
Err(error) => web_sys::console::error_1(&wasm_bindgen::JsValue::from_str(
&format!("ocs: failed to read dropped file \"{name}\": {error}"),
)),
}
});
}
},
);
let _ = document.add_event_listener_with_callback("drop", on_drop.as_ref().unchecked_ref());
on_drop.forget();
}
fn is_drawing_name(name: &str) -> bool {
name.rsplit_once('.').is_some_and(|(_, extension)| {
matches!(extension.to_ascii_lowercase().as_str(), "dwg" | "dxf")
})
}
/// Same Blob → bytes read as the OPFS recent copies (`web_recent`).
async fn read_file(file: &web_sys::File) -> Result<Vec<u8>, String> {
let buffer = JsFuture::from(file.array_buffer())
.await
.map_err(|error| error.as_string().unwrap_or_else(|| format!("{error:?}")))?;
Ok(js_sys::Uint8Array::new(&buffer).to_vec())
}