forked from 1b-agent/opencadstudio-upstream
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.
This commit is contained in:
1 parent
2a05479d69
commit
ad60be11ed
8 files changed
+362
-38
No files matched your search
@@ -1930,6 +1930,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,
|
||||
@@ -3519,6 +3523,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,
|
||||
@@ -4311,6 +4317,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,
|
||||
|
||||
+52
-10
@@ -1706,7 +1706,21 @@ impl OpenCADStudio {
|
||||
let mesh_refs: Vec<_> = meshes.iter().collect();
|
||||
let bytes = crate::io::stl::build_stl(&mesh_refs)
|
||||
.ok_or_else(|| "no mesh data to export".to_string())?;
|
||||
std::fs::write(&worker_path, bytes).map_err(|e| e.to_string())
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::fs::write(&worker_path, bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
// Web: no filesystem — the browser download names itself from
|
||||
// the virtual path.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let name = worker_path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "export.stl".into());
|
||||
crate::sys::download_bytes(&name, &bytes);
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
move |result| Message::StlExportFinished(path, result),
|
||||
)
|
||||
@@ -1727,7 +1741,21 @@ impl OpenCADStudio {
|
||||
let mesh_refs: Vec<_> = meshes.iter().collect();
|
||||
let text = crate::io::step::build_step(&mesh_refs)
|
||||
.ok_or_else(|| "no mesh data to export".to_string())?;
|
||||
std::fs::write(&worker_path, text.as_bytes()).map_err(|e| e.to_string())
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::fs::write(&worker_path, text.as_bytes()).map_err(|e| e.to_string())
|
||||
}
|
||||
// Web: no filesystem — the browser download names itself from
|
||||
// the virtual path.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let name = worker_path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "export.step".into());
|
||||
crate::sys::download_bytes(&name, text.as_bytes());
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
move |result| Message::StepExportFinished(path, result),
|
||||
)
|
||||
@@ -3465,19 +3493,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
@@ -376,11 +376,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(),
|
||||
@@ -800,6 +814,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) {
|
||||
@@ -1178,19 +1218,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),
|
||||
@@ -1218,19 +1276,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),
|
||||
@@ -1250,6 +1326,7 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
// ── OBJ import ────────────────────────────────────────────────
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
Message::ObjImport => Task::perform(
|
||||
async {
|
||||
crate::sys::file_dialog()
|
||||
@@ -1263,6 +1340,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(),
|
||||
|
||||
Reference in new issue
Block a user