Export the current 3D view as 2D DXF with optional hidden lines (#1449)

Adds File > Export > Current View as DXF: the visible bodies projected from the current camera onto 2D LINE/LWPOLYLINE entities, with hidden edges omitted or dashed.

Co-authored-by: cronos <19868002+crono2250@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-15 20:04:25 -04:00
1 parent 845f4913cf
commit c361630954
20 files changed
+798 -3

No files matched your search

+11
View File
@@ -258,6 +258,9 @@ pub enum Kind {
/// Surface tools and Split Face (`dialogs_surface`).
Surface(crate::dialogs_surface::Surf),
SaveMesh(crate::dialogs_save_mesh::SaveMesh),
ViewDxf {
dashed: bool,
},
/// Plastic features (`dialogs_plastic`).
Plastic(crate::dialogs_plastic::Pl),
/// Appearance (`dialogs_appearance`).
@@ -609,6 +612,7 @@ impl Dialog {
.or_else(|| crate::dialogs_sheet::start(app, id))
.or_else(|| crate::dialogs_surface::start(app, id))
.or_else(|| crate::dialogs_save_mesh::start(app, id))
.or_else(|| crate::dialogs_view_dxf::start(app, id))
.or_else(|| crate::dialogs_plastic::start(app, id))
.or_else(|| crate::dialogs_appearance::start(app, id))
.or_else(|| crate::dialogs_motion::start(app, id))
@@ -1379,6 +1383,7 @@ fn title(k: &Kind) -> &'static str {
Kind::Sheet(k) => k.title(),
Kind::Surface(k) => k.title(),
Kind::SaveMesh(_) => "SAVE AS MESH",
Kind::ViewDxf { .. } => "Export Current View as DXF",
Kind::Plastic(k) => k.title(),
Kind::Appearance(_) => "APPEARANCE",
Kind::Motion(k) => k.title(),
@@ -2773,6 +2778,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::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),
@@ -2835,6 +2841,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let raw_label = match &d.kind {
Kind::ConfirmDelete { .. } => "Delete",
Kind::SaveMesh(k) => crate::dialogs_save_mesh::button(k),
Kind::ViewDxf { .. } => "Export DXF…",
_ => "OK",
};
let label = tr(raw_label);
@@ -3278,6 +3285,9 @@ 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);
}
// Saving asks for a file name first; `Err("")` is the user backing out of that.
if let Kind::SaveMesh(k) = &d.kind {
return crate::dialogs_save_mesh::run(app, k, &d.inputs);
@@ -3975,6 +3985,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
Kind::Surface(k) => return crate::dialogs_surface::commands(app, k, &d.inputs, &d.extra),
Kind::SketchModify(k) => return crate::sketch_modify_ui::commands(app, k, &d.inputs, &d.extra),
Kind::SaveMesh(k) => return crate::dialogs_save_mesh::commands(k, &d.inputs),
Kind::ViewDxf { .. } => return Ok(Vec::new()),
Kind::Plastic(k) => return crate::dialogs_plastic::commands(app, k, &d.inputs, &d.extra),
Kind::Appearance(k) => return Ok(crate::dialogs_appearance::commands(k, &d.inputs)),
Kind::Motion(k) => return crate::dialogs_motion::commands(app, k, &d.inputs),
+88
View File
@@ -0,0 +1,88 @@
//! Export the camera at the instant Export is pressed, with a remembered hidden-edge choice.
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()))
}
pub fn rows(app: &SolveApp, ui: &mut egui::Ui, dashed: &mut bool) {
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"));
crate::scenario::publish_handle("dialog:dxf:omit", r.rect.center());
let r = ui.radio_value(dashed, true, app.tr_label("Export hidden edges as dashed lines"));
crate::scenario::publish_handle("dialog:dxf:dashed", 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.end_row();
}
pub fn run(app: &mut SolveApp, dashed: bool) -> 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)) {
return Err(app.tr_label("No visible bodies to export.").into());
}
app.preferences.view_dxf_dashed = dashed;
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);
}
}
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use solidcraft_engine::render::StandardView;
#[test]
fn view_dxf_file_menu_exports_with_the_current_camera_and_remembers_hidden_choice() {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.box", json!({"length":40,"width":30,"height":10})).unwrap();
h.app.run("solid.cylinder", json!({"radius":3,"height":4,"base":[10,10,0],"operation":"cut"})).unwrap();
h.app.cam.fov = 0.0;
h.app.cam.set_view(StandardView::Top);
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 })));
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| {
assert!(name.ends_with("-view.dxf"));
assert_eq!(extensions, &["dxf"]);
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);
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);
let saved = h.app.prefs();
let mut restored = SolveApp::new(Default::default(), Default::default());
restored.load_prefs(&saved);
assert!(restored.preferences.view_dxf_dashed);
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 })));
h.step(&json!({"click":{"handle":"dialog:dxf:omit"}})).unwrap();
h.step(&json!({"click":{"handle":"dialog:Export DXF…"}})).unwrap();
h.frames(3);
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);
}
}
+1
View File
@@ -35,6 +35,7 @@ pub mod dialogs_save_mesh;
pub mod dialogs_sheet;
pub mod dialogs_surface;
pub mod dialogs_sweep;
pub mod dialogs_view_dxf;
pub mod dim_view;
#[cfg(test)]
mod dimension_create_ui_tests;
+3
View File
@@ -328,6 +328,8 @@ pub struct Prefs {
/// Save as mesh: the last format (`stl` or `3mf`), whether it was sent to a slicer, which one
/// (an installed slicer's id or a program path), and the programs the user added (newest first).
pub save_mesh_format: String,
/// Hidden edges in the current-view DXF export (false: omit, true: dashed).
pub view_dxf_dashed: bool,
pub save_mesh_send: bool,
pub save_mesh_slicer: String,
pub custom_slicers: Vec<String>,
@@ -420,6 +422,7 @@ impl Default for Prefs {
check_updates: true,
update: crate::update::Memo::default(),
save_mesh_format: "stl".into(),
view_dxf_dashed: false,
save_mesh_send: false,
save_mesh_slicer: String::new(),
custom_slicers: Vec::new(),
+4
View File
@@ -448,6 +448,10 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
export(app, "step");
close = true;
}
if item_if(ui, app.tr_label("Export Current View as DXF…"), "", shown) {
app.start("file.export_view_dxf");
close = true;
}
if item_if(ui, "Export OBJ…", "", shown) {
export(app, "obj");
close = true;