Accelerate current-view DXF export and keep desktop exports responsive (#1738)

Thanks @crono2250.
This commit is contained in:
cronos authored and GitHub committed 2026-10-16 10:26:27 -04:00
1 parent 4bacd0cae0
commit bba03803b7
20 files changed
+1319 -53

No files matched your search

+4 -3
View File
@@ -260,6 +260,7 @@ pub enum Kind {
SaveMesh(crate::dialogs_save_mesh::SaveMesh),
ViewDxf {
dashed: bool,
tolerance: f64,
},
/// Plastic features (`dialogs_plastic`).
Plastic(crate::dialogs_plastic::Pl),
@@ -2948,7 +2949,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
Kind::Surface(k) => enter |= crate::dialogs_surface::rows(ui, k),
Kind::SketchModify(k) => enter |= crate::sketch_modify_ui::rows(ui, k),
Kind::SaveMesh(k) => enter |= crate::dialogs_save_mesh::rows(app, ui, k),
Kind::ViewDxf { dashed } => crate::dialogs_view_dxf::rows(app, ui, dashed),
Kind::ViewDxf { dashed, tolerance } => crate::dialogs_view_dxf::rows(app, ui, dashed, tolerance),
Kind::Plastic(k) => enter |= crate::dialogs_plastic::rows(app, ui, k, &d.inputs),
Kind::Appearance(k) => enter |= crate::dialogs_appearance::rows(app, ui, k, &mut d.inputs),
Kind::Motion(k) => enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs),
@@ -3477,8 +3478,8 @@ fn sels(d: &Dialog, i: usize) -> &[Sel] {
}
pub(crate) fn run_dialog(app: &mut SolveApp, d: &Dialog) -> Result<(), String> {
if let Kind::ViewDxf { dashed } = &d.kind {
return crate::dialogs_view_dxf::run(app, *dashed);
if let Kind::ViewDxf { dashed, tolerance } = &d.kind {
return crate::dialogs_view_dxf::run(app, *dashed, *tolerance);
}
// Saving asks for a file name first; `Err("")` is the user backing out of that.
if let Kind::SaveMesh(k) = &d.kind {
+54 -13
View File
@@ -3,10 +3,14 @@ use crate::{SolveApp, dialogs::Kind, selection::SelInput};
use serde_json::json;
pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
(id == "file.export_view_dxf").then(|| (Kind::ViewDxf { dashed: app.preferences.view_dxf_dashed }, Vec::new()))
(id == "file.export_view_dxf").then(|| {
let tolerance = app.preferences.view_dxf_tolerance;
let tolerance = if tolerance.is_finite() && (0.0001..=1.0).contains(&tolerance) { tolerance } else { 0.01 };
(Kind::ViewDxf { dashed: app.preferences.view_dxf_dashed, tolerance }, Vec::new())
})
}
pub fn rows(app: &SolveApp, ui: &mut egui::Ui, dashed: &mut bool) {
pub fn rows(app: &SolveApp, ui: &mut egui::Ui, dashed: &mut bool, tolerance: &mut f64) {
crate::dialogs::row_label(ui, "Hidden edges");
ui.vertical(|ui| {
let r = ui.radio_value(dashed, false, app.tr_label("Do not export hidden edges"));
@@ -15,34 +19,54 @@ pub fn rows(app: &SolveApp, ui: &mut egui::Ui, dashed: &mut bool) {
crate::scenario::publish_handle("dialog:dxf:dashed", r.rect.center());
});
ui.end_row();
crate::dialogs::row_label(ui, "dxf.tolerance");
let r = ui.add(egui::DragValue::new(tolerance).range(0.0001..=1.0).speed(0.001).max_decimals(4).suffix(" mm"));
crate::scenario::publish_handle("dialog:dxf:tolerance", r.rect.center());
ui.end_row();
crate::dialogs::row_label(ui, "Projection");
ui.label(app.tr_label(if app.cam.fov > 0.0 { "Current perspective view" } else { "Current orthographic view (1:1 mm)" }));
ui.end_row();
crate::dialogs::row_label(ui, "");
ui.label(app.tr_label("Visible bodies only. Curves use line segments (0.01 mm tolerance)."));
ui.add(egui::Label::new(app.tr_label("dxf.tolerance_help")).wrap());
ui.end_row();
}
pub fn run(app: &mut SolveApp, dashed: bool) -> Result<(), String> {
pub fn run(app: &mut SolveApp, dashed: bool, tolerance: f64) -> Result<(), String> {
if crate::viewport::section_plane(app).is_some() {
return Err(app.tr_label("Turn off section analysis before exporting the view.").into());
}
if !app.session.world_state().bodies.iter().any(|b| app.session.body_visible(&b.name)) {
if app.session.model.state().bodies.is_empty() {
return Err(app.tr_label("No visible bodies to export.").into());
}
if !tolerance.is_finite() || !(0.0001..=1.0).contains(&tolerance) {
return Err(app.tr_label("dxf.invalid_tolerance").into());
}
app.preferences.view_dxf_dashed = dashed;
app.preferences.view_dxf_tolerance = tolerance;
let camera = app.cam;
app.pick_save(&format!("{}-view.dxf", app.session.doc.name), &["dxf"], move |app, path| {
if let Some(path) = path {
let params = json!({"camera":camera,"hidden":if dashed {"dashed"} else {"omit"},"path":path});
if app.run("file.export_view_dxf", params).is_ok() {
app.set_status(crate::i18n::format("ui.saved_to", &[("path", &path)]), false);
}
let params = json!({"camera":camera,"hidden":if dashed {"dashed"} else {"omit"},"tolerance":tolerance,"path":path});
let mut request = crate::file_load::Request::new(&path, crate::file_load::Kind::ExportDxf);
request.options = params.as_object().cloned().unwrap_or_default();
let _ = crate::file_load::begin_request(app, request);
}
});
Ok(())
}
pub(crate) fn progress_text(work: Option<(&str, usize, usize)>) -> String {
let (stage, done, total) = work.unwrap_or(("dxf_meshes", 0, 1));
let label = match stage {
"dxf_index" => "dxf.progress.index",
"dxf_projection" => "dxf.progress.projection",
"dxf_occlusion" => "dxf.progress.occlusion",
"dxf_write" => "dxf.progress.write",
_ => "dxf.progress.meshes",
};
format!("{}: {done}/{total}", crate::i18n::tr_label(label))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -58,7 +82,11 @@ mod tests {
h.frames(3);
h.step(&json!({"click":{"handle":"appbar:folder"}})).unwrap();
h.step(&json!({"widget":"Export Current View as DXF…"})).unwrap();
assert!(matches!(h.app.dialog.as_ref().map(|d| &d.kind), Some(Kind::ViewDxf { dashed: false })));
assert!(matches!(h.app.dialog.as_ref().map(|d| &d.kind), Some(Kind::ViewDxf { dashed: false, .. })));
h.step(&json!({"click":{"handle":"dialog:dxf:tolerance"},"double":true})).unwrap();
h.step(&json!({"key":"A","cmd":true})).unwrap();
h.step(&json!({"text":"0.1"})).unwrap();
h.step(&json!({"key":"Tab"})).unwrap();
h.step(&json!({"click":{"handle":"dialog:dxf:dashed"}})).unwrap();
let dir = h.dir.clone();
h.app.services.pick_save = Some(Box::new(move |name, extensions| {
@@ -67,7 +95,7 @@ mod tests {
Box::pin(std::future::ready(Some(dir.join("view.dxf").to_string_lossy().into_owned())))
}));
h.step(&json!({"click":{"handle":"dialog:Export DXF…"}})).unwrap();
h.frames(3);
wait_for_export(&mut h);
let text = std::fs::read_to_string(h.dir.join("view.dxf")).unwrap();
assert!(text.contains("LWPOLYLINE") && text.contains("HIDDEN"));
assert!(h.app.dialog.is_none() && h.app.preferences.view_dxf_dashed);
@@ -75,14 +103,27 @@ mod tests {
let mut restored = SolveApp::new(Default::default(), Default::default());
restored.load_prefs(&saved);
assert!(restored.preferences.view_dxf_dashed);
assert_eq!(restored.preferences.view_dxf_tolerance, 0.1);
h.app.start("file.export_view_dxf");
h.frames(3);
assert!(matches!(h.app.dialog.as_ref().map(|d| &d.kind), Some(Kind::ViewDxf { dashed: true })));
assert!(matches!(h.app.dialog.as_ref().map(|d| &d.kind), Some(Kind::ViewDxf { dashed: true, tolerance: 0.1 })));
h.step(&json!({"click":{"handle":"dialog:dxf:omit"}})).unwrap();
h.step(&json!({"click":{"handle":"dialog:Export DXF…"}})).unwrap();
h.frames(3);
wait_for_export(&mut h);
let text = std::fs::read_to_string(h.dir.join("view.dxf")).unwrap();
assert!(!text.contains("LWPOLYLINE") && !text.contains("HIDDEN"));
assert!(!h.app.preferences.view_dxf_dashed);
}
fn wait_for_export(h: &mut crate::scenario::Harness) {
let began = std::time::Instant::now();
loop {
h.frames(1);
if h.app.file_load.idle() && !h.app.file_picker_busy() {
break;
}
assert!(began.elapsed().as_secs() < 10, "export did not finish");
std::thread::sleep(std::time::Duration::from_millis(1));
}
}
}
+129 -9
View File
@@ -29,6 +29,8 @@ pub enum Kind {
Insert,
/// Open a design an earlier run autosaved before it closed.
Recover { id: String, dir: Option<String> },
/// Export a snapshot of the current design without adopting the worker's session.
ExportDxf,
}
pub struct Request {
@@ -63,6 +65,7 @@ impl Request {
Kind::Insert if solidcraft_engine::io::is_mesh_path(&self.path) => ("file.insert_mesh", json!({"path": self.path})),
Kind::Insert => ("file.insert_step", self.params()),
Kind::Recover { id, dir } => ("doc.recover", json!({"id": id, "dir": dir})),
Kind::ExportDxf => ("file.export_view_dxf", Value::Object(self.options.clone())),
}
}
fn answer(&self, r: Result<Value, String>) {
@@ -99,6 +102,14 @@ struct Running {
staged: Option<Staged>,
}
impl Drop for Running {
fn drop(&mut self) {
if self.request.kind == Kind::ExportDxf {
self.progress.cancel();
}
}
}
/// A STEP import done a piece per frame, for the web app: it has one thread, which must keep
/// drawing (and offer Cancel) while a large file comes in. The file is parsed in one frame,
/// then its solids are converted a few at a time, each frame for about [`STAGED_FRAME_MS`];
@@ -208,6 +219,14 @@ pub fn begin_request(app: &mut SolveApp, request: Request) -> Result<(), String>
}
r
}
Kind::ExportDxf => {
let (command, params) = request.command();
let r = app.run(command, params);
if r.is_ok() && !request.path.is_empty() {
app.set_status(crate::i18n::format("ui.saved_to", &[("path", &request.path)]), false);
}
r
}
};
request.answer(r);
Ok(())
@@ -236,10 +255,15 @@ pub fn control(app: &mut SolveApp, req: crate::control::ControlRequest) -> Optio
let kind = match command {
Some("doc.open") => Kind::Open,
Some("file.insert_step" | "file.insert_mesh") => Kind::Insert,
Some("file.export_view_dxf") => Kind::ExportDxf,
_ => return Some(req),
};
let params = req.params.get("params");
let Some(path) = params.and_then(|p| p.get("path")).and_then(Value::as_str) else { return Some(req) };
let path = match params.and_then(|p| p.get("path")).and_then(Value::as_str) {
Some(path) => path,
None if kind == Kind::ExportDxf => "",
None => return Some(req),
};
let scope = match crate::control::request_scope(app, &req.params) {
Ok(scope) => scope,
Err(e) => {
@@ -247,7 +271,11 @@ pub fn control(app: &mut SolveApp, req: crate::control::ControlRequest) -> Optio
return None;
}
};
let options = ["up", "names"].iter().filter_map(|k| Some((k.to_string(), params?.get(*k).filter(|v| !v.is_null())?.clone()))).collect();
let options = if kind == Kind::ExportDxf {
params.and_then(Value::as_object).cloned().unwrap_or_default()
} else {
["up", "names"].iter().filter_map(|k| Some((k.to_string(), params?.get(*k).filter(|v| !v.is_null())?.clone()))).collect()
};
let _ = begin_request(app, Request { path: path.into(), kind, reply: Some(req.reply.clone()), scope, options });
None
}
@@ -281,7 +309,10 @@ fn start(app: &mut SolveApp) {
return;
}
let Some(request) = app.file_load.queued.pop_front() else { return };
if !request.insert() && app.docs.count() >= crate::documents::MAX_OPEN && !crate::documents::active_is_blank(app) {
if matches!(request.kind, Kind::Open | Kind::Recover { .. })
&& app.docs.count() >= crate::documents::MAX_OPEN
&& !crate::documents::active_is_blank(app)
{
let e = "too many open designs; close a tab before opening another";
app.set_status(e, true);
request.answer(Err(e.into()));
@@ -289,7 +320,7 @@ fn start(app: &mut SolveApp) {
}
let insert = request.insert();
let before = if insert { app.session.world_state().bodies.iter().map(|b| b.name.clone()).collect() } else { Vec::new() };
let mut scratch = if insert { app.session.scratch() } else { Session::default() };
let mut scratch = if insert || request.kind == Kind::ExportDxf { app.session.scratch() } else { Session::default() };
if insert {
scratch.selection = app.session.selection.clone();
}
@@ -679,6 +710,11 @@ fn compute(
let result =
solidcraft_engine::io::vfs::scoped(scope, || solidcraft_engine::doc::progress::watch(progress.clone(), || session.execute(command, params)))
.map_err(|e| e.to_string())?;
if command == "file.export_view_dxf" {
// A successful export has already published its complete file. A Cancel arriving
// after that commit must not turn a completed save into a misleading cancellation.
return Ok(Made { session, result, image: None });
}
if progress.is_cancelled() {
return cancelled();
}
@@ -739,6 +775,12 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
{
let request = &running.request;
match job {
Ok(made) if request.kind == Kind::ExportDxf => {
if !request.path.is_empty() {
app.set_status(crate::i18n::format("ui.saved_to", &[("path", &request.path)]), false);
}
request.answer(Ok(made.result));
}
_ if running.cancelled() => request.answer(Err("file operation cancelled".into())),
Ok(made) if running.matches(&app.session) => {
if request.insert() {
@@ -813,7 +855,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let phase = r.phase.load(Ordering::Relaxed);
let (done, total) = r.progress.features();
// Long work inside one feature (a large STEP file) has progress of its own.
let work = r.progress.work().filter(|(_, d, t)| phase == 0 && d < t);
let export = r.request.kind == Kind::ExportDxf;
let work = r.progress.work().filter(|(_, d, t)| export || (phase == 0 && d < t));
let screen = ctx.content_rect();
// Leave the title bar to the window. The resize edges are a foreground layer above this.
let cover = egui::Rect::from_min_max(egui::pos2(screen.left(), screen.top() + crate::toolbar::APP_BAR_HEIGHT), screen.max);
@@ -848,20 +891,23 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
ui.set_width(width - 16.0);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(if cancelled {
egui::RichText::new(if cancelled && export {
crate::i18n::tr_label("dxf.cancelling")
} else if cancelled {
"Waiting for file operation"
} else {
match r.request.kind {
Kind::Insert => "Inserting geometry",
Kind::Open => "Opening design",
Kind::Recover { .. } => "Recovering design",
Kind::ExportDxf => crate::i18n::tr_label("dxf.exporting"),
}
})
.strong()
.size(14.0),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let b = ui.button("Cancel");
let b = ui.button(crate::i18n::tr("ui.cancel"));
crate::scenario::publish_handle("file_loading:cancel", b.rect.center());
cancel |= b.clicked();
});
@@ -879,10 +925,13 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.spinner();
ui.label(if cancelled {
ui.label(if cancelled && export {
crate::i18n::tr_label("dxf.cancel_wait").to_string()
} else if cancelled {
"Waiting for the previous operation to stop...".to_string()
} else {
match (phase, work) {
(_, work) if r.request.kind == Kind::ExportDxf => crate::dialogs_view_dxf::progress_text(work),
(_, Some(("reading", d, t))) => format!("Reading the file... {}%", d * 100 / t.max(1)),
(_, Some((_, d, t))) => format!("Converting solid {} of {t}", d + 1),
(1, _) => "Preparing the view...".into(),
@@ -912,7 +961,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
// import to overlap it in memory. The rebuild stops at its next feature and the
// current design stays as it was.
app.file_load.cancel();
app.set_status("file operation cancelled", false);
app.set_status(if export { crate::i18n::tr_label("dxf.cancelled") } else { "file operation cancelled" }, false);
}
}
@@ -920,6 +969,77 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
mod tests {
use super::*;
#[test]
fn dxf_export_progress_keeps_frames_alive_and_never_adopts_a_worker_document() {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
h.frames(3);
let base = h.app.session.doc.clone();
let undo = h.app.session.undo.len();
let cam = h.app.cam;
let progress = Arc::new(Progress::default());
solidcraft_engine::doc::progress::watch(progress.clone(), || {
solidcraft_engine::kernel::watch::current().unwrap().progress("dxf_occlusion", 64, 128);
});
let (tx, rx) = std::sync::mpsc::channel();
h.app.file_load.running = Some(Running {
request: Request::new("drawing.dxf", Kind::ExportDxf),
base: base.clone(),
base_revision: h.app.session.revision,
before: Vec::new(),
rx,
phase: Arc::new(AtomicU8::new(0)),
progress: progress.clone(),
preview_rx: None,
preview: None,
partial_rx: None,
built: None,
staged: None,
});
// No worker result is available: drawing a frame must not wait for one.
h.frames(4);
assert!(h.app.file_load.busy());
let at = crate::scenario::handle_at("file_loading:cancel").unwrap();
let (request, _reply) = crate::control::ControlRequest::new("ui.click", json!({"x":at.x,"y":at.y}));
let _ = crate::control::handle(&mut h.app, &h.ctx, &request);
h.frames(4);
assert!(progress.is_cancelled());
tx.send(Err("DXF export cancelled".into())).unwrap();
h.frames(2);
assert!(h.app.file_load.idle());
assert!(Arc::ptr_eq(&base, &h.app.session.doc));
assert_eq!(h.app.session.undo.len(), undo);
assert_eq!(h.app.cam, cam);
// The success path is equally read-only, even if the worker has another session.
let mut other = Session::default();
other.execute("file.new", &json!({"name":"Worker must not replace the current design"})).unwrap();
let mut request = Request::new("drawing.dxf", Kind::ExportDxf);
let (reply_tx, reply_rx) = std::sync::mpsc::channel();
request.reply = Some(reply_tx);
let (tx, rx) = std::sync::mpsc::channel();
h.app.file_load.running = Some(Running {
request,
base: base.clone(),
base_revision: h.app.session.revision,
before: Vec::new(),
rx,
phase: Arc::new(AtomicU8::new(0)),
progress: Arc::new(Progress::default()),
preview_rx: None,
preview: None,
partial_rx: None,
built: None,
staged: None,
});
tx.send(Ok(Made { session: other, result: json!({"visible":12}), image: None })).unwrap();
h.frames(2);
assert_eq!(reply_rx.try_recv().unwrap()["result"]["visible"], 12);
assert!(Arc::ptr_eq(&base, &h.app.session.doc));
assert_eq!(h.app.session.undo.len(), undo);
assert_eq!(h.app.cam, cam);
}
#[test]
fn opening_result_and_preview_cannot_overwrite_a_newer_design() {
for (insert, change_document) in [(false, true), (true, true), (false, false)] {
+3
View File
@@ -332,6 +332,8 @@ pub struct Prefs {
pub save_mesh_format: String,
/// Hidden edges in the current-view DXF export (false: omit, true: dashed).
pub view_dxf_dashed: bool,
/// Model-space chord tolerance for current-view DXF curves, in millimetres.
pub view_dxf_tolerance: f64,
pub save_mesh_send: bool,
pub save_mesh_slicer: String,
pub custom_slicers: Vec<String>,
@@ -428,6 +430,7 @@ impl Default for Prefs {
update: crate::update::Memo::default(),
save_mesh_format: "stl".into(),
view_dxf_dashed: false,
view_dxf_tolerance: 0.01,
save_mesh_send: false,
save_mesh_slicer: String::new(),
custom_slicers: Vec::new(),