From c36163095490cb8683275f23ce03fa6cdc603897 Mon Sep 17 00:00:00 2001 From: cronos Date: Fri, 16 Oct 2026 09:04:25 +0900 Subject: [PATCH] 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 --- README.md | 3 + ROADMAP.md | 5 + assets/locales/de.json | 12 + assets/locales/en.json | 12 + assets/locales/ja.json | 12 + assets/locales/ko.json | 12 + assets/locales/zh.json | 12 + crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/view_dxf.rs | 101 ++++++ crates/engine/src/cmd/view_dxf_projection.rs | 342 +++++++++++++++++++ crates/engine/src/cmd/view_dxf_tests.rs | 102 ++++++ crates/engine/src/tests/path_scope.rs | 1 + crates/io/src/dxf_out.rs | 39 ++- crates/ui-egui/src/dialogs.rs | 11 + crates/ui-egui/src/dialogs_view_dxf.rs | 88 +++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/prefs.rs | 3 + crates/ui-egui/src/toolbar.rs | 4 + docs/features.md | 5 +- docs/view-dxf.md | 34 ++ 20 files changed, 798 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/cmd/view_dxf.rs create mode 100644 crates/engine/src/cmd/view_dxf_projection.rs create mode 100644 crates/engine/src/cmd/view_dxf_tests.rs create mode 100644 crates/ui-egui/src/dialogs_view_dxf.rs create mode 100644 docs/view-dxf.md diff --git a/README.md b/README.md index daaf675a..3b11f3dd 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,9 @@ rebuild with no errors, replayed here at 2× speed. ## What works +- **Current-view DXF export** from File → Export Current View as DXF, with hidden edges + omitted or dashed, shown body placements and the current camera. See [view DXF](docs/view-dxf.md). + - **Sketches** on origin planes, offset planes or planar faces: lines, rectangles, circles, arcs, polygons, slots, ellipses, fit/control splines, conics and text; construction geometry, projection, trimming, fillets and chamfers, including corners at the origin. diff --git a/ROADMAP.md b/ROADMAP.md index c9a64dbb..68b10195 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -66,6 +66,11 @@ pointer movement orbits without a mouse button, and vertical scrolling zooms. Ot retain their saved two-finger scroll preference; camera motion is available through `view.navigate` and modifier-aware `ui.move` automation. +M11 precursor: File → Export Current View as DXF exports shown placed bodies from the current +camera as a 2D millimetre drawing, with hidden edges omitted or dashed. Vector occlusion splits +partly hidden edges and includes curved silhouettes. Curves are approximated; drawing sheets, +annotations and exact projected curves remain deferred. See [view DXF](docs/view-dxf.md). + ## Interactive modelling - New Body extrusion and face-offset previews show completed intermediate geometry while a diff --git a/assets/locales/de.json b/assets/locales/de.json index 42755ebd..a3e96c2b 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -1,6 +1,18 @@ { "settings.measure_axis_distances": "X/Y/Z-Maße anzeigen", "settings.measure_axis_distances_help": "Bei geöffneter Messung die Abstände entlang der Weltachsen zusätzlich zum Gesamtabstand anzeigen.", + "Export Current View as DXF": "Aktuelle Ansicht als DXF exportieren", + "Export Current View as DXF…": "Aktuelle Ansicht als DXF exportieren…", + "Hidden edges": "Verdeckte Kanten", + "Do not export hidden edges": "Verdeckte Kanten nicht exportieren", + "Export hidden edges as dashed lines": "Verdeckte Kanten gestrichelt exportieren", + "Projection": "Projektion", + "Current perspective view": "Aktuelle perspektivische Ansicht", + "Current orthographic view (1:1 mm)": "Aktuelle orthogonale Ansicht (1:1 mm)", + "Visible bodies only. Curves use line segments (0.01 mm tolerance).": "Nur sichtbare Körper. Kurven werden durch Liniensegmente angenähert (Toleranz: 0,01 mm).", + "Turn off section analysis before exporting the view.": "Deaktivieren Sie die Schnittanalyse, bevor Sie die Ansicht exportieren.", + "No visible bodies to export.": "Keine sichtbaren Körper zum Exportieren.", + "Export DXF…": "DXF exportieren…", "Send your report on GitHub": "Bericht auf GitHub senden", "1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. Unten den GitHub-Entwurf öffnen und den kopierten Bericht aus der Zwischenablage einfügen.", "1. Open GitHub draft below. Your title and report will be filled in.": "1. Unten den GitHub-Entwurf öffnen. Titel und Bericht werden vorausgefüllt.", diff --git a/assets/locales/en.json b/assets/locales/en.json index ac454dc7..6c392dd4 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -1,6 +1,18 @@ { "settings.measure_axis_distances": "Show X/Y/Z distances", "settings.measure_axis_distances_help": "Show world-axis component dimensions alongside the total distance while Measure is open.", + "Export Current View as DXF": "Export Current View as DXF", + "Export Current View as DXF…": "Export Current View as DXF…", + "Hidden edges": "Hidden edges", + "Do not export hidden edges": "Do not export hidden edges", + "Export hidden edges as dashed lines": "Export hidden edges as dashed lines", + "Projection": "Projection", + "Current perspective view": "Current perspective view", + "Current orthographic view (1:1 mm)": "Current orthographic view (1:1 mm)", + "Visible bodies only. Curves use line segments (0.01 mm tolerance).": "Visible bodies only. Curves use line segments (0.01 mm tolerance).", + "Turn off section analysis before exporting the view.": "Turn off section analysis before exporting the view.", + "No visible bodies to export.": "No visible bodies to export.", + "Export DXF…": "Export DXF…", "Send your report on GitHub": "Send your report on GitHub", "1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. Open GitHub draft below, then paste the report copied to your clipboard.", "1. Open GitHub draft below. Your title and report will be filled in.": "1. Open GitHub draft below. Your title and report will be filled in.", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index f71689d7..d338ae8e 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -1,6 +1,18 @@ { "settings.measure_axis_distances": "計測のX・Y・Z距離を表示", "settings.measure_axis_distances_help": "計測ダイアログを開いている間、2点間距離に加えてワールドX・Y・Z軸方向の寸法線と距離を表示します。", + "Export Current View as DXF": "現在の表示をDXFでエクスポート", + "Export Current View as DXF…": "現在の表示をDXFでエクスポート…", + "Hidden edges": "隠線", + "Do not export hidden edges": "出力しない", + "Export hidden edges as dashed lines": "点線で出力", + "Projection": "投影", + "Current perspective view": "現在の透視投影", + "Current orthographic view (1:1 mm)": "現在の平行投影(実寸・mm)", + "Visible bodies only. Curves use line segments (0.01 mm tolerance).": "表示中のボディを出力します。曲線は線分で近似します(許容誤差0.01 mm)。", + "Turn off section analysis before exporting the view.": "断面解析をオフにしてからエクスポートしてください。", + "No visible bodies to export.": "出力する表示中のボディがありません。", + "Export DXF…": "DXFをエクスポート…", "Send your report on GitHub": "GitHubでレポートを送信", "1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. 下のボタンでGitHubの下書きを開き、クリップボードにコピーされたレポートを貼り付けます。", "1. Open GitHub draft below. Your title and report will be filled in.": "1. 下のボタンでGitHubの下書きを開きます。タイトルとレポートは入力済みです。", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 758975dd..4d5596fb 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -1,6 +1,18 @@ { "settings.measure_axis_distances": "X/Y/Z 거리 표시", "settings.measure_axis_distances_help": "측정 대화상자가 열려 있는 동안 전체 거리와 함께 월드 축 방향 치수와 거리를 표시합니다.", + "Export Current View as DXF": "현재 뷰를 DXF로 내보내기", + "Export Current View as DXF…": "현재 뷰를 DXF로 내보내기…", + "Hidden edges": "숨겨진 모서리", + "Do not export hidden edges": "숨겨진 모서리 내보내지 않기", + "Export hidden edges as dashed lines": "숨겨진 모서리를 파선으로 내보내기", + "Projection": "투영", + "Current perspective view": "현재 원근 투영 뷰", + "Current orthographic view (1:1 mm)": "현재 직교 투영 뷰 (1:1 mm)", + "Visible bodies only. Curves use line segments (0.01 mm tolerance).": "표시된 바디만 내보냅니다. 곡선은 선분으로 근사합니다 (허용오차 0.01 mm).", + "Turn off section analysis before exporting the view.": "뷰를 내보내기 전에 단면 분석을 끄세요.", + "No visible bodies to export.": "표시된 바디가 없어 내보낼 수 없습니다.", + "Export DXF…": "DXF 내보내기…", "Send your report on GitHub": "GitHub에서 보고서 보내기", "1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. 아래에서 GitHub 초안을 열고 클립보드에 복사된 보고서를 붙여넣으세요.", "1. Open GitHub draft below. Your title and report will be filled in.": "1. 아래에서 GitHub 초안을 여세요. 제목과 보고서가 자동으로 입력됩니다.", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 4d3a0aa7..bc14ab2b 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -1,6 +1,18 @@ { "settings.measure_axis_distances": "显示 X/Y/Z 距离", "settings.measure_axis_distances_help": "测量对话框打开时,在总距离之外显示沿世界坐标轴方向的尺寸线和距离。", + "Export Current View as DXF": "将当前视图导出为DXF", + "Export Current View as DXF…": "将当前视图导出为DXF…", + "Hidden edges": "隐藏线", + "Do not export hidden edges": "不导出隐藏线", + "Export hidden edges as dashed lines": "以虚线导出隐藏线", + "Projection": "投影", + "Current perspective view": "当前透视视图", + "Current orthographic view (1:1 mm)": "当前正交视图(1:1毫米)", + "Visible bodies only. Curves use line segments (0.01 mm tolerance).": "仅导出可见实体。曲线以线段近似(公差0.01毫米)。", + "Turn off section analysis before exporting the view.": "请关闭截面分析后再导出视图。", + "No visible bodies to export.": "没有可导出的可见实体。", + "Export DXF…": "导出DXF…", "Send your report on GitHub": "在 GitHub 上发送报告", "1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. 点击下方按钮打开 GitHub 草稿,然后粘贴已复制到剪贴板的报告。", "1. Open GitHub draft below. Your title and report will be filled in.": "1. 点击下方按钮打开 GitHub 草稿。标题和报告将自动填入。", diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index bb6ed59d..7c1353ca 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -74,6 +74,7 @@ mod sketch_modify; mod sketch_project; mod surface; mod surface_analysis; +mod view_dxf; mod view_navigation; use serde::Serialize; @@ -265,6 +266,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(libraries::COMMANDS.iter()); v.extend(section::COMMANDS.iter()); v.extend(view_navigation::COMMANDS.iter()); + v.extend(view_dxf::COMMANDS.iter()); v.extend(diagnostics::COMMANDS.iter()); v.extend(render::COMMANDS.iter()); v diff --git a/crates/engine/src/cmd/view_dxf.rs b/crates/engine/src/cmd/view_dxf.rs new file mode 100644 index 00000000..3a18f0db --- /dev/null +++ b/crates/engine/src/cmd/view_dxf.rs @@ -0,0 +1,101 @@ +//! A drawing of the visible bodies from a supplied viewport camera. No screen pixels, grid, +//! selection highlights or tessellation diagonals are exported. + +#[path = "view_dxf_projection.rs"] +mod projection; + +use serde_json::{Value, json}; +use solidcraft_io::{DxfEntity, DxfLayer, DxfLinetype, DxfShape}; +use solidcraft_render::Camera; + +use super::CommandSpec; +use crate::params::{bad, str_}; +use crate::{Result, Session}; + +const CMD: &str = "file.export_view_dxf"; +pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Export Current View as DXF", export) + .icon("export") + .noundo() + .params("camera: Camera (current view); hidden?: omit|dashed (default omit); tolerance?: 0.0001..1 mm (curve chord tolerance, default 0.01); path?: .dxf file (else returns dxf text). Visible placed bodies only; 2D LINE/LWPOLYLINE entities on VISIBLE/HIDDEN layers, mm; perspective projected onto the camera target plane; no viewport crop or overlays.")]; + +fn export(s: &mut Session, p: &Value) -> Result { + let cam: Camera = + serde_json::from_value(p.get("camera").cloned().ok_or_else(|| bad(CMD, "camera is required"))?).map_err(|_| bad(CMD, "invalid camera"))?; + if !cam.is_valid() + || !(0.0..std::f64::consts::PI).contains(&cam.fov) + || cam.pitch.abs() > std::f64::consts::FRAC_PI_2 + || cam.yaw.abs() > 1e6 + || cam.roll.abs() > 1e6 + { + return Err(bad(CMD, "invalid camera")); + } + let dashed = match p.get("hidden") { + None => false, + Some(Value::String(v)) if v == "omit" => false, + Some(Value::String(v)) if v == "dashed" => true, + _ => return Err(bad(CMD, "hidden must be omit or dashed")), + }; + let tolerance = match p.get("tolerance") { + None => 0.01, + Some(v) => v.as_f64().filter(|t| (0.0001..=1.0).contains(t)).ok_or_else(|| bad(CMD, "tolerance must be 0.0001..1 mm"))?, + }; + let path = match p.get("path") { + None => None, + Some(_) => Some(str_(p, "path").filter(|v| !v.trim().is_empty() && v.len() < 4096).ok_or_else(|| bad(CMD, "invalid path"))?), + }; + if let Some(path) = path { + solidcraft_io::vfs::check(path).map_err(|e| crate::EngineError::Other(e.to_string()))?; + } + let world = s.world_state(); + let mut meshes = Vec::new(); + let mut count = 0usize; + for body in world.bodies.iter().filter(|b| s.body_visible(&b.name)) { + let mesh = body.body.reference_display_mesh_angular(tolerance, solidcraft_kernel::DISPLAY_ANGLE)?; + count = count.saturating_add(mesh.triangles.len()).saturating_add(mesh.edges.iter().map(Vec::len).sum::()); + if count > 1_000_000 { + return Err(bad(CMD, "view too complex to export; hide bodies or increase tolerance")); + } + if mesh.triangles.is_empty() { + return Err(bad(CMD, format!("{} has no exportable surface", body.name))); + } + meshes.push(mesh); + } + if meshes.is_empty() { + return Err(bad(CMD, "no visible bodies to export")); + } + let lines = projection::drawing(&meshes, &cam).map_err(|e| bad(CMD, e))?; + let mut visible = 0; + let mut hidden = 0; + let mut entities = Vec::new(); + for line in lines { + if line.hidden && !dashed { + continue; + } + let layer = if line.hidden { + hidden += 1; + "HIDDEN" + } else { + visible += 1; + "VISIBLE" + }; + entities.push(DxfEntity { layer: layer.into(), shape: DxfShape::Line(line.a, line.b) }); + } + let entities = projection::connect(entities); + let mut layers = vec![DxfLayer::new("VISIBLE", 7)]; + if dashed { + layers.push(DxfLayer { linetype: DxfLinetype::Hidden, ..DxfLayer::new("HIDDEN", 8) }); + } + let text = solidcraft_io::write_dxf_drawing(&layers, &entities); + let mut result = json!({"visible":visible,"hidden":hidden,"units":"mm","tolerance":tolerance,"projection":if cam.fov > 0.0 {"perspective"} else {"orthographic"}}); + if let Some(path) = path { + solidcraft_io::vfs::write(path, text.as_bytes()).map_err(|e| crate::EngineError::Other(e.to_string()))?; + result["path"] = json!(path); + } else { + result["dxf"] = json!(text); + } + Ok(result) +} + +#[cfg(test)] +#[path = "view_dxf_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/view_dxf_projection.rs b/crates/engine/src/cmd/view_dxf_projection.rs new file mode 100644 index 00000000..c496b2ac --- /dev/null +++ b/crates/engine/src/cmd/view_dxf_projection.rs @@ -0,0 +1,342 @@ +//! Vector hidden-line removal: intersect each projected edge with triangle half planes and +//! its depth plane, then union occluded parameter intervals. A BVH bounds the candidate work. +use solidcraft_geom::{Aabb3, Mesh, Vec2, Vec3}; +use solidcraft_io::{DxfEntity, DxfShape}; +use solidcraft_render::Camera; +use std::collections::BTreeMap; + +/// Keep the hidden curves connected: restarting a dash on every short chord would make a +/// small circular hole look solid. Degree-two joins continue; junctions end the polyline. +pub(super) fn connect(entities: Vec) -> Vec { + let mut result = Vec::new(); + let mut lines = Vec::new(); + for entity in entities { + match entity { + DxfEntity { layer, shape: DxfShape::Line(a, b) } if layer == "HIDDEN" => lines.push((a, b)), + e => result.push(e), + } + } + let extent = lines.iter().flat_map(|(a, b)| [a, b]).fold(1.0_f64, |s, p| s.max(p.x.abs()).max(p.y.abs())); + let eps = extent * 1e-8; + let key = |p: Vec2| [(p.x / eps).round() as i64, (p.y / eps).round() as i64]; + let mut nodes: BTreeMap<[i64; 2], Vec> = BTreeMap::new(); + for (i, (a, b)) in lines.iter().enumerate() { + nodes.entry(key(*a)).or_default().push(i); + nodes.entry(key(*b)).or_default().push(i); + } + let mut used = vec![false; lines.len()]; + for (i, (a, b)) in lines.iter().enumerate() { + if used.get(i).copied().unwrap_or(true) { + continue; + } + if let Some(v) = used.get_mut(i) { + *v = true; + } + let mut points = vec![*a, *b]; + for _ in 0..2 { + while let Some(end) = points.last().copied() { + let Some(neighbours) = nodes.get(&key(end)).filter(|n| n.len() == 2) else { break }; + let Some(next) = neighbours.iter().copied().find(|i| used.get(*i) == Some(&false)) else { break }; + let Some((a, b)) = lines.get(next) else { break }; + if let Some(v) = used.get_mut(next) { + *v = true; + } + points.push(if key(*a) == key(end) { *b } else { *a }); + } + points.reverse(); + } + result.push(DxfEntity { layer: "HIDDEN".into(), shape: DxfShape::Polyline { points } }); + } + result +} + +pub(super) struct Line { + pub a: Vec2, + pub b: Vec2, + pub hidden: bool, +} + +fn xy(p: Vec3) -> Vec2 { + Vec2::new(p.x, p.y) +} +fn cross(a: Vec2, b: Vec2) -> f64 { + a.x * b.y - a.y * b.x +} +fn bounds(points: impl IntoIterator) -> Aabb3 { + let mut b = Aabb3::EMPTY; + for p in points { + b.add(p); + } + b +} +fn overlap(a: &Aabb3, b: &Aabb3) -> bool { + a.min.x <= b.max.x && b.min.x <= a.max.x && a.min.y <= b.max.y && b.min.y <= a.max.y +} + +struct Triangle { + p: [Vec3; 3], + bounds: Aabb3, +} +struct Tree { + bounds: Aabb3, + children: Option<(Box, Box)>, + triangles: Vec, +} +impl Tree { + fn new(mut triangles: Vec) -> Self { + let b = triangles.iter().fold(Aabb3::EMPTY, |b, t| b.union(&t.bounds)); + if triangles.len() <= 12 { + return Self { bounds: b, children: None, triangles }; + } + let x = b.size().x > b.size().y; + let middle = triangles.len() / 2; + triangles.select_nth_unstable_by(middle, |a, b| { + let c = |t: &Triangle| if x { t.bounds.center().x } else { t.bounds.center().y }; + c(a).total_cmp(&c(b)) + }); + let right = triangles.split_off(middle); + Self { bounds: b, children: Some((Box::new(Self::new(triangles)), Box::new(Self::new(right)))), triangles: Vec::new() } + } + fn intervals(&self, a: Vec3, b: Vec3, bx: &Aabb3, eps: f64, out: &mut Vec<(f64, f64)>, budget: &mut usize) -> Result<(), &'static str> { + *budget = budget.checked_sub(1).ok_or("view too complex to export; hide some bodies")?; + if !overlap(&self.bounds, bx) || self.bounds.max.z <= a.z.min(b.z) + eps { + return Ok(()); + } + if let Some((left, right)) = &self.children { + left.intervals(a, b, bx, eps, out, budget)?; + right.intervals(a, b, bx, eps, out, budget)?; + } else { + for t in &self.triangles { + if overlap(&t.bounds, bx) + && let Some(span) = occluded(a, b, t.p, eps) + { + out.push(span); + } + } + } + Ok(()) + } +} + +/// Clip [lo,hi] by the linear inequality f(t) >= 0. +fn clip(lo: &mut f64, hi: &mut f64, f0: f64, f1: f64) -> bool { + if f0 < 0.0 && f1 < 0.0 { + return false; + } + if (f0 < 0.0) != (f1 < 0.0) { + let at = f0 / (f0 - f1); + if f0 < 0.0 { + *lo = lo.max(at); + } else { + *hi = hi.min(at); + } + } + *hi > *lo +} +fn occluded(a: Vec3, b: Vec3, t: [Vec3; 3], eps: f64) -> Option<(f64, f64)> { + let [p, q, r] = t; + let area = cross(xy(q - p), xy(r - p)); + if area.abs() < 1e-20 { + return None; + } + let (mut lo, mut hi) = (0.0, 1.0); + for (u, v) in [(p, q), (q, r), (r, p)] { + let side = |w: Vec3| cross(xy(v - u), xy(w - u)) * area.signum(); + if !clip(&mut lo, &mut hi, side(a), side(b)) { + return None; + } + } + let depth = |w: Vec3| { + let s = cross(xy(w - p), xy(r - p)) / area; + let u = cross(xy(q - p), xy(w - p)) / area; + p.z + s * (q.z - p.z) + u * (r.z - p.z) - w.z - eps + }; + clip(&mut lo, &mut hi, depth(a), depth(b)).then_some((lo, hi)) +} + +fn key(p: Vec3, eps: f64) -> [i64; 3] { + [(p.x / eps).round() as i64, (p.y / eps).round() as i64, (p.z / eps).round() as i64] +} + +/// Merge collinear projected intervals, giving visible strokes precedence. This also removes +/// a rear edge underneath only part of a visible edge, and rejoins subdivided silhouettes. +fn merge_lines(lines: Vec, eps: f64) -> Vec { + type Events = (Vec2, Vec2, Vec<(f64, i32, i32)>); + let mut groups: BTreeMap<[i64; 3], Events> = BTreeMap::new(); + for line in lines { + let delta = line.b - line.a; + let mut dir = delta / delta.len(); + if (dir.x.abs() > 1e-8 && dir.x < 0.0) || (dir.x.abs() <= 1e-8 && dir.y < 0.0) { + dir = -dir; + } + let offset = cross(dir, line.a); + let k = [(dir.x / 1e-8).round() as i64, (dir.y / 1e-8).round() as i64, (offset / eps).round() as i64]; + let (origin, dir, events) = groups.entry(k).or_insert((line.a, dir, Vec::new())); + let (a, b) = ((line.a - *origin).dot(*dir), (line.b - *origin).dot(*dir)); + let (v, h) = if line.hidden { (0, 1) } else { (1, 0) }; + events.push((a.min(b), v, h)); + events.push((a.max(b), -v, -h)); + } + let mut result = Vec::new(); + for (_, (origin, dir, mut events)) in groups { + events.sort_unstable_by(|a, b| a.0.total_cmp(&b.0)); + let (mut visible, mut hidden, mut last) = (0, 0, 0.0); + let mut path: Option = None; + for (at, v, h) in events { + if at - last > eps { + if visible > 0 || hidden > 0 { + let is_hidden = visible == 0; + let a = origin + dir * last; + let b = origin + dir * at; + if let Some(p) = path.as_mut().filter(|p| p.hidden == is_hidden && p.b.dist(a) <= eps) { + p.b = b; + } else { + if let Some(p) = path.take() { + result.push(p); + } + path = Some(Line { a, b, hidden: is_hidden }); + } + } else if let Some(p) = path.take() { + result.push(p); + } + } + visible += v; + hidden += h; + last = at; + } + if let Some(p) = path { + result.push(p); + } + } + result +} + +/// B-rep edges plus curved silhouettes. Triangle adjacency is welded by position because +/// different faces can have different vertex indices along their shared boundary. +fn edges(mesh: &Mesh, cam: &Camera, eps: f64) -> Vec<(Vec3, Vec3)> { + let mut result = Vec::new(); + for (_, e) in mesh.edges.iter().enumerate().filter(|(i, _)| mesh.shaded_edge_visible(*i)) { + result.extend(e.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?)))); + } + let mut adjacent: BTreeMap<_, (Vec3, Vec3, bool, bool, usize)> = BTreeMap::new(); + for indices in &mesh.triangles { + let Some([a, b, c]) = mesh.tri(indices) else { continue }; + let n = (b - a).cross(c - a); + let toward = if cam.fov > 0.0 { cam.eye() - (a + b + c) / 3.0 } else { cam.basis().2 }; + let front = n.dot(toward) > 0.0; + for (a, b) in [(a, b), (b, c), (c, a)] { + let (ka, kb) = (key(a, eps), key(b, eps)); + let k = if ka < kb { (ka, kb) } else { (kb, ka) }; + let e = adjacent.entry(k).or_insert((a, b, false, false, 0)); + e.2 |= front; + e.3 |= !front; + e.4 += 1; + } + } + for (_, (a, b, front, back, n)) in adjacent { + if (front && back) || n == 1 { + result.push((a, b)); + } + } + result +} + +pub(super) fn drawing(meshes: &[Mesh], cam: &Camera) -> Result, &'static str> { + let (right, up, back) = cam.basis(); + let scale = meshes.iter().fold(Aabb3::EMPTY, |b, m| b.union(&m.bounds())).diagonal().max(1.0); + let eps = scale * 1e-8; + let project = |p: Vec3| -> Result { + if !p.is_finite() { + return Err("model contains invalid coordinates"); + } + let d = p - cam.target; + let z = d.dot(back); + let projected = if cam.fov > 0.0 { + let w = cam.distance - z; + if w <= cam.distance * 1e-6 { + return Err("model crosses the camera eye plane; zoom out before exporting"); + } + Vec3::new(d.dot(right) * cam.distance / w, d.dot(up) * cam.distance / w, 1.0 / w) + } else { + Vec3::new(d.dot(right), d.dot(up), z) + }; + if !projected.is_finite() || projected.len() > 1e12 { + return Err("projected coordinates exceed the export range"); + } + Ok(projected) + }; + let mut triangles = Vec::new(); + let mut segments = Vec::new(); + for m in meshes { + for t in &m.triangles { + if let Some([a, b, c]) = m.tri(t) { + let p = [project(a)?, project(b)?, project(c)?]; + triangles.push(Triangle { p, bounds: bounds(p) }); + } + } + for (a, b) in edges(m, cam, eps) { + let (a, b) = (project(a)?, project(b)?); + if xy(a).dist(xy(b)) > eps { + segments.push((a, b)); + } + } + } + let tree = Tree::new(triangles); + let depth_eps = if cam.fov > 0.0 { 1e-8 / cam.distance } else { eps }; + let mut result = Vec::new(); + let mut budget = 20_000_000usize; + for (a, b) in segments { + let mut spans = Vec::new(); + tree.intervals(a, b, &bounds([a, b]), depth_eps, &mut spans, &mut budget)?; + spans.sort_unstable_by(|a, b| a.0.total_cmp(&b.0)); + let mut merged: Vec<(f64, f64)> = Vec::new(); + for (lo, hi) in spans { + if let Some(last) = merged.last_mut().filter(|last| lo <= last.1 + 1e-9) { + last.1 = last.1.max(hi); + } else { + merged.push((lo, hi)); + } + } + let mut emit = |lo: f64, hi: f64, hidden: bool| { + let (a, b) = (xy(a + (b - a) * lo), xy(a + (b - a) * hi)); + if a.dist(b) <= eps { + return; + } + result.push(Line { a, b, hidden }); + }; + let mut cursor = 0.0; + for (lo, hi) in merged { + emit(cursor, lo, false); + emit(lo, hi, true); + cursor = hi; + } + emit(cursor, 1.0, false); + if result.len() > 1_000_000 { + return Err("view too complex to export; hide some bodies"); + } + } + Ok(merge_lines(result, eps)) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn a_visible_edge_overrides_only_the_overlapping_part_of_a_hidden_edge() { + let lines = + vec![Line { a: Vec2::ZERO, b: Vec2::new(10., 0.), hidden: true }, Line { a: Vec2::new(3., 0.), b: Vec2::new(7., 0.), hidden: false }]; + let merged = merge_lines(lines, 1e-8); + assert_eq!(merged.len(), 3); + let length = |hidden| merged.iter().filter(|l| l.hidden == hidden).map(|l| l.a.dist(l.b)).sum::(); + assert!((length(false) - 4.).abs() < 1e-8 && (length(true) - 6.).abs() < 1e-8); + } + #[test] + fn a_line_is_split_at_triangle_boundaries_and_depth_crossings() { + let t = [Vec3::new(0., 0., 1.), Vec3::new(2., 0., 1.), Vec3::new(0., 2., 1.)]; + let (lo, hi) = occluded(Vec3::new(-1., 0.5, 0.), Vec3::new(3., 0.5, 0.), t, 0.).unwrap(); + assert!((lo - 0.25).abs() < 1e-9 && (hi - 0.625).abs() < 1e-9); + assert!(occluded(Vec3::new(0., 0.5, 2.), Vec3::new(1., 0.5, 2.), t, 0.).is_none()); + let (lo, hi) = occluded(Vec3::new(0., 0.5, 0.), Vec3::new(1., 0.5, 2.), t, 0.).unwrap(); + assert!(lo.abs() < 1e-9 && (hi - 0.5).abs() < 1e-9); + } +} diff --git a/crates/engine/src/cmd/view_dxf_tests.rs b/crates/engine/src/cmd/view_dxf_tests.rs new file mode 100644 index 00000000..3b6242a6 --- /dev/null +++ b/crates/engine/src/cmd/view_dxf_tests.rs @@ -0,0 +1,102 @@ +use super::*; +use solidcraft_geom::{Vec2, Vec3}; +use solidcraft_render::StandardView; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap() +} +fn sample() -> Session { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":40,"width":30,"height":10,"body_name":"Plate"})); + // A blind hole on the bottom, completely hidden from above. + run(&mut s, "solid.cylinder", json!({"radius":3,"height":4,"base":[10,10,0],"operation":"cut","targets":["Plate"]})); + s +} +fn camera(view: StandardView) -> Camera { + let mut c = Camera { target: Vec3::new(20., 15., 5.), fov: 0., distance: 150., ..Default::default() }; + c.set_view(view); + c +} +fn export(s: &mut Session, c: Camera, hidden: &str) -> Value { + run(s, CMD, json!({"camera":c,"hidden":hidden})) +} + +#[test] +fn view_dxf_blind_back_hole_is_omitted_or_dashed_and_front_hole_is_visible() { + let mut s = sample(); + let undo = s.undo.len(); + let doc = s.doc.clone(); + let top = export(&mut s, camera(StandardView::Top), "omit"); + let dashed = export(&mut s, camera(StandardView::Top), "dashed"); + assert!(top["visible"].as_u64().unwrap() > 0); + assert_eq!(top["hidden"], 0); + assert!(dashed["hidden"].as_u64().unwrap() > 12, "{dashed}"); + assert_eq!(top["visible"], dashed["visible"]); + assert!(!top["dxf"].as_str().unwrap().contains("HIDDEN")); + assert!(dashed["dxf"].as_str().unwrap().contains("LWPOLYLINE")); + assert!(dashed["dxf"].as_str().unwrap().contains("128\n")); + let bottom = export(&mut s, camera(StandardView::Bottom), "omit"); + assert!(bottom["visible"].as_u64().unwrap() > top["visible"].as_u64().unwrap()); + assert_eq!(s.undo.len(), undo); + assert!(std::sync::Arc::ptr_eq(&doc, &s.doc)); +} + +#[test] +fn view_dxf_respects_hidden_bodies_mm_and_camera_roll() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":40,"width":30,"height":10,"body_name":"Plate"})); + run(&mut s, "solid.box", json!({"length":5,"width":5,"height":5,"corner":[100,0,0],"body_name":"Other"})); + run(&mut s, "browser.visibility", json!({"items":["Other"],"visible":false})); + let mut c = camera(StandardView::Top); + let drawing = export(&mut s, c, "omit"); + let size = |v: &Value| { + let mut points = Vec::new(); + for g in solidcraft_io::read_dxf(v["dxf"].as_str().unwrap()).unwrap() { + if let solidcraft_io::Geom2::Line(a, b) = g { + points.extend([a, b]); + } + } + let min = points.iter().fold(Vec2::new(f64::INFINITY, f64::INFINITY), |a, p| Vec2::new(a.x.min(p.x), a.y.min(p.y))); + let max = points.iter().fold(Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY), |a, p| Vec2::new(a.x.max(p.x), a.y.max(p.y))); + max - min + }; + let d = size(&drawing); + assert!((d.x - 40.).abs() < 0.001 && (d.y - 30.).abs() < 0.001, "{d:?}"); + c.roll = std::f64::consts::FRAC_PI_2; + let d = size(&export(&mut s, c, "omit")); + assert!((d.x - 30.).abs() < 0.001 && (d.y - 40.).abs() < 0.001, "{d:?}"); + run(&mut s, "browser.visibility", json!({"items":["Plate"],"visible":false})); + assert!(s.execute(CMD, &json!({"camera":c})).is_err()); +} + +#[test] +fn view_dxf_cylinders_have_side_silhouettes_and_perspective_is_supported() { + let mut s = Session::default(); + run(&mut s, "solid.cylinder", json!({"radius":10,"height":20})); + let mut c = camera(StandardView::Front); + c.target = Vec3::new(0., 0., 10.); + let v = export(&mut s, c, "omit"); + let lines = solidcraft_io::read_dxf(v["dxf"].as_str().unwrap()).unwrap(); + assert!(lines.iter().filter(|g| matches!(g,solidcraft_io::Geom2::Line(a,b) if (a.y-b.y).abs()>19.9)).count() >= 2); + c.fov = 0.6; + let v = export(&mut s, c, "dashed"); + assert_eq!(v["projection"], "perspective"); + assert!(v["visible"].as_u64().unwrap() > 0); +} + +#[test] +fn view_dxf_rejects_invalid_options_and_near_eye_geometry_without_panicking() { + let mut s = sample(); + for extra in [json!({"hidden":"all"}), json!({"hidden":true}), json!({"tolerance":0}), json!({"tolerance":"x"}), json!({"path":false})] { + let mut p = extra; + p["camera"] = json!(camera(StandardView::Top)); + assert!(s.execute(CMD, &p).is_err(), "{p}"); + } + assert!(s.execute(CMD, &json!({})).is_err()); + let mut c = camera(StandardView::Top); + c.fov = 0.5; + c.distance = 1.; + assert!(s.execute(CMD, &json!({"camera":c})).is_err()); + c.fov = 4.; + assert!(s.execute(CMD, &json!({"camera":c})).is_err()); +} diff --git a/crates/engine/src/tests/path_scope.rs b/crates/engine/src/tests/path_scope.rs index 3f2961a1..ebab2546 100644 --- a/crates/engine/src/tests/path_scope.rs +++ b/crates/engine/src/tests/path_scope.rs @@ -29,6 +29,7 @@ fn file_commands_stay_inside_the_agents_folders() { ("file.save_as", json!({"path": at(&out, "x.solidcraft")})), ("file.save", json!({"path": at(&root, "../out/x.solidcraft")})), ("file.export", json!({"path": at(&out, "x.stl")})), + ("file.export_view_dxf", json!({"path": at(&out, "x.dxf"),"camera":render::Camera::default()})), ("file.export", json!({"path": at(&out, "x.json"), "format": "json"})), ("file.save_as", json!({"path": at(&out, "x.json"), "format": "json", "preview": true})), ("parameters.export", json!({"path": at(&out, "x.csv")})), diff --git a/crates/io/src/dxf_out.rs b/crates/io/src/dxf_out.rs index 46569895..bba7f847 100644 --- a/crates/io/src/dxf_out.rs +++ b/crates/io/src/dxf_out.rs @@ -22,6 +22,8 @@ pub enum DxfLinetype { Continuous, /// AutoCAD's DASHED: a dash of 12.7 mm and a gap of 6.35 mm. Dashed, + /// Short 2 mm dashes and 1 mm gaps for hidden edges in millimetre drawings. + Hidden, } impl DxfLinetype { @@ -29,6 +31,7 @@ impl DxfLinetype { match self { DxfLinetype::Continuous => "Continuous", DxfLinetype::Dashed => "DASHED", + DxfLinetype::Hidden => "HIDDEN", } } } @@ -57,6 +60,10 @@ impl DxfLayer { #[derive(Clone, Debug, PartialEq)] pub enum DxfShape { Line(Vec2, Vec2), + /// Connected straight segments, with continuous linetype generation through the vertices. + Polyline { + points: Vec, + }, Circle { c: Vec2, r: f64, @@ -207,6 +214,7 @@ impl DxfShape { fn sample(&self, out: &mut Vec) { match self { DxfShape::Line(a, b) => out.extend([*a, *b]), + DxfShape::Polyline { points } => out.extend(points.iter().copied()), DxfShape::Circle { c, r } => out.extend([*c - Vec2::new(*r, *r), *c + Vec2::new(*r, *r)]), DxfShape::Arc { c, r, a0, a1 } => { let sweep = (a1 - a0).rem_euclid(360.0); @@ -348,12 +356,15 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> String o.code(2, "TABLES"); // The three every drawing has, and DASHED, which the dashed layers name: dash and gap lengths // (a gap is negative) after the pattern's total. - let linetypes: [(&str, &str, &[f64]); 4] = [ + let mut linetypes: Vec<(&str, &str, &[f64])> = vec![ ("ByBlock", "", &[]), ("ByLayer", "", &[]), ("Continuous", "Solid line", &[]), ("DASHED", "Dashed __ __ __ __ __ __ __ __ __ __", &[12.7, -6.35]), ]; + if layer_list.iter().any(|l| l.linetype == DxfLinetype::Hidden) { + linetypes.push(("HIDDEN", "Hidden edges", &[2.0, -1.0])); + } let t = o.table("LTYPE", linetypes.len()); for (name, description, pattern) in linetypes { o.record("LTYPE", "AcDbLinetypeTableRecord", &t); @@ -414,6 +425,7 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> String for e in entities { let kind = match &e.shape { DxfShape::Line(..) => "LINE", + DxfShape::Polyline { .. } => "LWPOLYLINE", DxfShape::Circle { .. } => "CIRCLE", DxfShape::Arc { .. } => "ARC", DxfShape::Ellipse { .. } => "ELLIPSE", @@ -427,6 +439,16 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> String o.code(100, "AcDbEntity"); o.code(8, if e.layer.trim().is_empty() { "0" } else { e.layer.as_str() }); match &e.shape { + DxfShape::Polyline { points } => { + o.code(100, "AcDbPolyline"); + o.code(90, points.len()); + // Autodesk DXF reference: bit 128 (PLINEGEN) continues the dash pattern. + o.code(70, 128); + for p in points { + o.num(10, p.x); + o.num(20, p.y); + } + } DxfShape::Line(a, b) => { o.code(100, "AcDbLine"); o.point(10, *a); @@ -811,6 +833,21 @@ mod tests { ); } + #[test] + fn hidden_polylines_continue_short_dashes_across_chords_and_round_trip() { + let points = vec![Vec2::ZERO, Vec2::new(1., 0.), Vec2::new(2., 1.), Vec2::new(3., 1.)]; + let layer = DxfLayer { linetype: DxfLinetype::Hidden, ..DxfLayer::new("HIDDEN", 8) }; + let text = write_dxf_drawing(&[layer], &[DxfEntity { layer: "HIDDEN".into(), shape: DxfShape::Polyline { points } }]); + let records = entities(&text); + assert_eq!(records[0].0, "LWPOLYLINE"); + assert_eq!(field(&records[0].1, 70), "128"); + assert_eq!(field(&records[0].1, 90), "4"); + let types = table_records(&pairs(&text), "LTYPE"); + let hidden = types.iter().find(|r| field(r, 2) == "HIDDEN").unwrap(); + assert_eq!(hidden.iter().filter(|(c, _)| *c == 49).map(|(_, v)| v.parse::().unwrap()).collect::>(), [2., -1.]); + assert_eq!(read_dxf(&text).unwrap().len(), 3); + } + #[test] fn text_is_centred_on_its_point_and_the_reader_gets_it_back() { let at = Vec2::new(30.0, 12.0); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 47305032..280c1973 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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, 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), diff --git a/crates/ui-egui/src/dialogs_view_dxf.rs b/crates/ui-egui/src/dialogs_view_dxf.rs new file mode 100644 index 00000000..cbf0222b --- /dev/null +++ b/crates/ui-egui/src/dialogs_view_dxf.rs @@ -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)> { + (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); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b0026ec6..e90c6e18 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 6a81f899..a7c28b5f 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -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, @@ -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(), diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 9ae138f4..c414ceec 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -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; diff --git a/docs/features.md b/docs/features.md index 304b2008..29ae43bc 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,6 +1,6 @@ # Features -SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **376 commands.** Planned (registered, not built yet): 3. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). +SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **377 commands.** Planned (registered, not built yet): 3. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). ## Sketch (102) @@ -323,7 +323,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Compare Bodies | `inspect.compare` | – | | Section Analysis | `inspect.section` | SOLID › INSPECT | -## Files (8) +## Files (9) | Command | Id | Toolbar | |---|---|---| @@ -335,6 +335,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Export | `file.export` | – | | Save As Mesh | `file.save_mesh` | MESH › MODIFY | | Installed Slicers | `file.slicers` | – | +| Export Current View as DXF | `file.export_view_dxf` | – | ## Documents (4) diff --git a/docs/view-dxf.md b/docs/view-dxf.md new file mode 100644 index 00000000..9fa3e3fd --- /dev/null +++ b/docs/view-dxf.md @@ -0,0 +1,34 @@ +# Export the current view as DXF + +Choose **File → Export Current View as DXF…**, choose whether to omit hidden edges or +export them as dashed lines, then choose **Export DXF…** and a destination. The hidden-edge +choice is remembered between runs. The Japanese menu is **ファイル → 現在の表示をDXFでエクスポート…**. + +The drawing uses the camera when Export is pressed, including roll, the world's up axis +and perspective. Only shown bodies are exported, at their current component/occurrence +placements. Hidden bodies neither draw nor hide other bodies. The complete projected +geometry is exported, including geometry beyond the viewport's edges. Grid, sketches, +dimensions, selection highlights and other overlays are excluded. Turn off section analysis +before exporting. + +Coordinates are 2D, Z=0, in millimetres. Orthographic views retain 1:1 dimensions in the +view plane. Perspective views retain foreshortening, projected onto the plane through the +camera target; they are intended for illustrations rather than dimensioned fabrication. +Curves and curved silhouettes are approximated with straight segments at a default +model-space chord tolerance of 0.01 mm. This is not an exact analytic curve export. + +The AutoCAD 2000 ASCII DXF contains `VISIBLE` (continuous) and, when requested, `HIDDEN` +(2 mm dashes / 1 mm gaps) layers. Hidden curves use connected `LWPOLYLINE` entities with +continuous linetype generation so the pattern does not restart on each chord. The pattern +is embedded in the file. The polyline flags follow the +[Autodesk DXF reference](https://help.autodesk.com/cloudhelp/2017/ENU/AutoCAD-DXF/files/GUID-748FC305-F3F2-4F74-825A-61F04D757A50.htm). + +Edges are split where another surface occludes them, including partial occlusion by another +body. Hidden edges are never included in omit mode. Triangle diagonals and smooth seams +are excluded; curved-body silhouettes are included. Very complex drawings fail with an +error rather than returning an incomplete export. If geometry crosses the perspective +camera's eye plane, zoom out before exporting. + +Programmatic callers use `file.export_view_dxf` with `camera: Camera`, `hidden: "omit"` or +`"dashed"`, optional `tolerance` (0.0001–1 mm) and optional `path`. Without `path`, the +command returns the DXF text. The command does not modify the design or its undo history.