From bba03803b7a1eae942518417dd7fd758caa28d83 Mon Sep 17 00:00:00 2001 From: cronos Date: Fri, 16 Oct 2026 23:26:27 +0900 Subject: [PATCH] Accelerate current-view DXF export and keep desktop exports responsive (#1738) Thanks @crono2250. --- ROADMAP.md | 9 +- 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/view_dxf.rs | 38 +- crates/engine/src/cmd/view_dxf_meshes.rs | 207 ++++++ crates/engine/src/cmd/view_dxf_progress.rs | 35 ++ crates/engine/src/cmd/view_dxf_projection.rs | 27 +- crates/engine/src/cmd/view_dxf_scene.rs | 627 +++++++++++++++++++ crates/engine/src/cmd/view_dxf_tests.rs | 72 +++ crates/kernel/src/lib.rs | 2 +- crates/kernel/src/par.rs | 44 ++ crates/kernel/src/watch.rs | 2 +- crates/ui-egui/src/dialogs.rs | 7 +- crates/ui-egui/src/dialogs_view_dxf.rs | 67 +- crates/ui-egui/src/file_load.rs | 138 +++- crates/ui-egui/src/prefs.rs | 3 + docs/view-dxf.md | 34 +- 20 files changed, 1319 insertions(+), 53 deletions(-) create mode 100644 crates/engine/src/cmd/view_dxf_meshes.rs create mode 100644 crates/engine/src/cmd/view_dxf_progress.rs create mode 100644 crates/engine/src/cmd/view_dxf_scene.rs diff --git a/ROADMAP.md b/ROADMAP.md index 67e1ba71..456a400c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -68,9 +68,16 @@ retain their saved two-finger scroll preference; camera motion is available thro 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, +partly hidden edges and includes curved silhouettes. Repeated part meshes and triangle +hierarchies are shared; bounded desktop preparation and occlusion use up to eight workers. +Curves are approximated; drawing sheets, annotations and exact projected curves remain deferred. See [view DXF](docs/view-dxf.md). +Desktop view DXF export runs in the file-operation worker with five-stage progress and +cooperative cancellation. Curve tolerance (0.0001–1 mm, default 0.01 mm) is remembered alongside +the hidden-edge setting. Cancelled exports preserve the model and the previous destination; +completed drawings publish atomically. The browser still exports synchronously. + ## 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 a9b927c9..7c2a2440 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -431,6 +431,18 @@ "drive.stopped_by_contact": "Angehalten bei {value}: {a} würde {b} durchdringen", "drive.stopped_by_loop": "Angehalten bei {value}: die Schleife über {joint} lässt sich nicht weiter schließen", "drive.unconnected": "Bewegt nichts: {part} ist mit nichts Festgesetztem verbunden", + "dxf.cancel_wait": "Warten, bis der Export angehalten wurde…", + "dxf.cancelled": "DXF-Export abgebrochen", + "dxf.cancelling": "DXF-Export wird abgebrochen", + "dxf.exporting": "DXF wird exportiert", + "dxf.invalid_tolerance": "Die Kurventoleranz muss zwischen 0,0001 und 1 mm liegen.", + "dxf.progress.index": "Flächen vorbereiten (2/5)", + "dxf.progress.meshes": "Netze vorbereiten (1/5)", + "dxf.progress.occlusion": "Verdeckte Kanten ermitteln (4/5)", + "dxf.progress.projection": "Geometrie projizieren (3/5)", + "dxf.progress.write": "DXF schreiben (5/5)", + "dxf.tolerance": "Kurventoleranz", + "dxf.tolerance_help": "Nur sichtbare Körper. Kurven werden durch gerade Segmente angenähert. Eine kleinere Toleranz liefert feinere Kurven und benötigt mehr Zeit und Speicher.", "home.documentation": "Dokumentation", "home.in_this_browser": "in diesem Browser", "home.missing": "fehlt", diff --git a/assets/locales/en.json b/assets/locales/en.json index b2238047..7e316a01 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -634,6 +634,18 @@ "drive.stopped_by_contact": "Stopped at {value}: {a} would pass through {b}", "drive.stopped_by_loop": "Stopped at {value}: the loop through {joint} can't close any further", "drive.unconnected": "Moves nothing: {part} is not connected to anything grounded", + "dxf.cancel_wait": "Waiting for the export to stop…", + "dxf.cancelled": "DXF export cancelled", + "dxf.cancelling": "Cancelling DXF export", + "dxf.exporting": "Exporting DXF", + "dxf.invalid_tolerance": "Curve tolerance must be between 0.0001 and 1 mm.", + "dxf.progress.index": "Preparing surfaces (2/5)", + "dxf.progress.meshes": "Preparing meshes (1/5)", + "dxf.progress.occlusion": "Resolving hidden lines (4/5)", + "dxf.progress.projection": "Projecting geometry (3/5)", + "dxf.progress.write": "Writing DXF (5/5)", + "dxf.tolerance": "Curve tolerance", + "dxf.tolerance_help": "Visible bodies only. Curves use straight segments. Smaller tolerance gives finer curves and uses more time and memory.", "expression, e.g. 25 mm": "expression, e.g. 25 mm", "fits the face, e.g. M8": "fits the face, e.g. M8", "from the rule": "from the rule", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 9086717a..0ad9c1e8 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -634,6 +634,18 @@ "drive.stopped_by_contact": "{value} で停止: {a} が {b} を通り抜けてしまいます", "drive.stopped_by_loop": "{value} で停止: {joint} を通るループはこれ以上閉じられません", "drive.unconnected": "何も動きません: {part} は空間固定されたものに接続されていません", + "dxf.cancel_wait": "出力処理の停止を待っています…", + "dxf.cancelled": "DXF出力をキャンセルしました", + "dxf.cancelling": "DXF出力をキャンセル中", + "dxf.exporting": "DXFを出力中", + "dxf.invalid_tolerance": "曲線の許容誤差は0.0001~1 mmで指定してください。", + "dxf.progress.index": "面データを準備中 (2/5)", + "dxf.progress.meshes": "メッシュを準備中 (1/5)", + "dxf.progress.occlusion": "隠線を判定中 (4/5)", + "dxf.progress.projection": "形状を投影中 (3/5)", + "dxf.progress.write": "DXFを書き込み中 (5/5)", + "dxf.tolerance": "曲線の許容誤差", + "dxf.tolerance_help": "表示中のボディを出力し、曲線は線分で近似します。許容誤差を小さくすると曲線が滑らかになりますが、処理時間とメモリ使用量が増えます。", "expression, e.g. 25 mm": "計算式 (例: 25 mm)", "fits the face, e.g. M8": "面に適合するサイズ (例: M8)", "from the rule": "ルールから", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 5d2e3538..35aceaf0 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -431,6 +431,18 @@ "drive.stopped_by_contact": "{value}에서 멈춤: {a}이(가) {b}을(를) 통과하게 됩니다", "drive.stopped_by_loop": "{value}에서 멈춤: {joint}을(를) 지나는 루프를 더 이상 닫을 수 없습니다", "drive.unconnected": "아무것도 움직이지 않습니다: {part}이(가) 고정된 것에 연결되어 있지 않습니다", + "dxf.cancel_wait": "내보내기가 중지될 때까지 기다리는 중…", + "dxf.cancelled": "DXF 내보내기를 취소했습니다", + "dxf.cancelling": "DXF 내보내기 취소 중", + "dxf.exporting": "DXF 내보내는 중", + "dxf.invalid_tolerance": "곡선 허용 오차는 0.0001~1 mm 범위여야 합니다.", + "dxf.progress.index": "면 데이터 준비 중 (2/5)", + "dxf.progress.meshes": "메시 준비 중 (1/5)", + "dxf.progress.occlusion": "숨은선 판정 중 (4/5)", + "dxf.progress.projection": "형상 투영 중 (3/5)", + "dxf.progress.write": "DXF 쓰는 중 (5/5)", + "dxf.tolerance": "곡선 허용 오차", + "dxf.tolerance_help": "표시된 바디만 내보내며 곡선은 직선 구간으로 근사합니다. 허용 오차가 작을수록 곡선이 매끄러워지지만 처리 시간과 메모리 사용량이 늘어납니다.", "home.documentation": "문서", "home.in_this_browser": "이 브라우저에 저장됨", "home.missing": "찾을 수 없음", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index e9acd94e..e04cf783 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -634,6 +634,18 @@ "drive.stopped_by_contact": "停在 {value}:{a} 会穿过 {b}", "drive.stopped_by_loop": "停在 {value}:经过 {joint} 的环路无法再闭合", "drive.unconnected": "没有任何移动:{part} 未连接到任何固定的零部件", + "dxf.cancel_wait": "正在等待导出停止…", + "dxf.cancelled": "DXF 导出已取消", + "dxf.cancelling": "正在取消 DXF 导出", + "dxf.exporting": "正在导出 DXF", + "dxf.invalid_tolerance": "曲线公差必须介于 0.0001 和 1 mm 之间。", + "dxf.progress.index": "正在准备曲面 (2/5)", + "dxf.progress.meshes": "正在准备网格 (1/5)", + "dxf.progress.occlusion": "正在判定隐藏线 (4/5)", + "dxf.progress.projection": "正在投影几何体 (3/5)", + "dxf.progress.write": "正在写入 DXF (5/5)", + "dxf.tolerance": "曲线公差", + "dxf.tolerance_help": "仅导出可见实体。曲线使用直线段近似。公差越小,曲线越精细,但需要更多时间和内存。", "expression, e.g. 25 mm": "表达式,例如 25 mm", "fits the face, e.g. M8": "适配该面,例如 M8", "from the rule": "来自规则", diff --git a/crates/engine/src/cmd/view_dxf.rs b/crates/engine/src/cmd/view_dxf.rs index 3a18f0db..c6248841 100644 --- a/crates/engine/src/cmd/view_dxf.rs +++ b/crates/engine/src/cmd/view_dxf.rs @@ -1,6 +1,10 @@ //! 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_meshes.rs"] +mod meshes; +#[path = "view_dxf_progress.rs"] +mod progress; #[path = "view_dxf_projection.rs"] mod projection; @@ -46,28 +50,17 @@ fn export(s: &mut Session, p: &Value) -> Result { 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 started = std::time::Instant::now(); + progress::Stage::new("dxf_meshes", 1).check().map_err(|e| bad(CMD, e))?; + let meshes = meshes::prepare(s, tolerance)?; + let prepare_ms = started.elapsed().as_secs_f64() * 1000.; + let drawing = projection::drawing(&meshes, &cam).map_err(|e| bad(CMD, e))?; + let writing = progress::Stage::new("dxf_write", 1); + writing.check().map_err(|e| bad(CMD, e))?; let mut visible = 0; let mut hidden = 0; let mut entities = Vec::new(); - for line in lines { + for line in drawing.lines { if line.hidden && !dashed { continue; } @@ -86,9 +79,14 @@ fn export(s: &mut Session, p: &Value) -> Result { layers.push(DxfLayer { linetype: DxfLinetype::Hidden, ..DxfLayer::new("HIDDEN", 8) }); } let text = solidcraft_io::write_dxf_drawing(&layers, &entities); + writing.check().map_err(|e| bad(CMD, e))?; let mut result = json!({"visible":visible,"hidden":hidden,"units":"mm","tolerance":tolerance,"projection":if cam.fov > 0.0 {"perspective"} else {"orthographic"}}); + result["stats"] = json!({"bodies":meshes.placements.len(),"shared_meshes":meshes.meshes.len(),"triangles":meshes.triangles, + "mesh_bytes":meshes.bytes,"worker_limit":meshes::WORKERS,"segments":drawing.segments,"occlusion_work":drawing.work, + "prepare_ms":prepare_ms,"projection_ms":drawing.projection_ms,"occlusion_ms":drawing.occlusion_ms}); if let Some(path) = path { - solidcraft_io::vfs::write(path, text.as_bytes()).map_err(|e| crate::EngineError::Other(e.to_string()))?; + solidcraft_io::vfs::check(path).map_err(|e| crate::EngineError::Other(e.to_string()))?; + solidcraft_io::write_atomic(std::path::Path::new(path), text.as_bytes(), false).map_err(|e| crate::EngineError::Other(e.to_string()))?; result["path"] = json!(path); } else { result["dxf"] = json!(text); diff --git a/crates/engine/src/cmd/view_dxf_meshes.rs b/crates/engine/src/cmd/view_dxf_meshes.rs new file mode 100644 index 00000000..ec3f0e4d --- /dev/null +++ b/crates/engine/src/cmd/view_dxf_meshes.rs @@ -0,0 +1,207 @@ +//! Export-only mesh instances. Keep one geometry buffer per unambiguous definition, while +//! retaining the current placed body's edge choices (tangent classification can vary after +//! placement). Never modify the document, fuse parts, or share across separate exports. +use std::collections::BTreeMap; +use std::mem::size_of; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use solidcraft_doc::{IDENTITY, Mat, ModelBody, mat_inverse, mat_mul}; +use solidcraft_geom::{Aabb3, Mesh, Vec3}; +use solidcraft_kernel::{KernelError, map_with_local_pools}; + +use super::{CMD, progress::Stage}; +use crate::params::bad; +use crate::{Result, Session}; + +#[cfg(not(target_arch = "wasm32"))] +pub(super) const WORKERS: usize = 8; +#[cfg(target_arch = "wasm32")] +pub(super) const WORKERS: usize = 1; +const MESH_BYTES: usize = 512 * 1024 * 1024; +const MAX_BODIES: usize = 100_000; + +pub(super) struct Placement { + pub mesh: usize, + pub transform: Mat, + pub visibility: Vec, +} +pub(super) struct Scene { + pub meshes: Vec>, + pub placements: Vec, + pub triangles: usize, + pub bytes: usize, +} + +struct Group { + first: usize, + inverse: Mat, +} +struct Item<'a> { + body: &'a ModelBody, + group: usize, + keep: bool, + transform: Mat, +} +struct Prepared { + mesh: Option>, + visibility: Vec, + triangles: usize, +} + +pub(super) fn claim(counter: &AtomicUsize, amount: usize, limit: usize) -> bool { + let mut old = counter.load(Ordering::Relaxed); + loop { + let Some(next) = old.checked_add(amount).filter(|n| *n <= limit) else { return false }; + match counter.compare_exchange_weak(old, next, Ordering::Relaxed, Ordering::Relaxed) { + Ok(_) => return true, + Err(current) => old = current, + } + } +} + +pub(super) fn buffer_bytes(m: &Mesh) -> usize { + m.positions.capacity() * size_of::() + + m.normals.capacity() * size_of::() + + m.triangles.capacity() * size_of::<[u32; 3]>() + + m.tri_face.capacity() * size_of::() + + m.edges.capacity() * size_of::>() + + m.edges.iter().map(|e| e.capacity() * size_of::()).sum::() + + m.seams.capacity() + + m.smooth_edges.capacity() + + m.sharp_edges.capacity() + + m.edge_faces.capacity() * size_of::>() + + m.edge_faces.iter().map(|e| e.capacity() * size_of::()).sum::() +} + +fn rigid(m: &Mat) -> bool { + if m.iter().flatten().any(|v| !v.is_finite()) || m.iter().zip([0., 0., 0., 1.]).any(|(c, w)| (c[3] - w).abs() > 1e-10) { + return false; + } + let axes = [Vec3::new(m[0][0], m[0][1], m[0][2]), Vec3::new(m[1][0], m[1][1], m[1][2]), Vec3::new(m[2][0], m[2][1], m[2][2])]; + let [x, y, z] = axes; + (x.len2() - 1.).abs() < 1e-9 + && (y.len2() - 1.).abs() < 1e-9 + && (z.len2() - 1.).abs() < 1e-9 + && x.dot(y).abs() < 1e-9 + && x.dot(z).abs() < 1e-9 + && y.dot(z).abs() < 1e-9 + && (x.cross(y).dot(z) - 1.).abs() < 1e-9 +} + +pub(super) fn prepare(s: &Session, tolerance: f64) -> Result { + let world = s.world_state(); + let state = s.model.state(); + let mut definitions = BTreeMap::new(); + for body in &state.bodies { + *definitions.entry((body.component, body.definition_name(), body.feature)).or_insert(0usize) += 1; + } + let mut by_definition = BTreeMap::new(); + let mut groups: Vec = Vec::new(); + let mut items = Vec::new(); + for (index, body) in world.bodies.iter().enumerate().filter(|(_, b)| s.body_visible(&b.name)) { + if items.len() >= MAX_BODIES { + return Err(bad(CMD, "too many placed bodies to export")); + } + let key = (body.component, body.definition_name(), body.feature); + let unambiguous = definitions.get(&key) == Some(&1); + let reused = unambiguous + .then(|| by_definition.get(&key).copied()) + .flatten() + .and_then(|g: usize| groups.get(g).map(|group| (g, mat_mul(&body.source_transform, &group.inverse)))) + .filter(|(_, m)| rigid(m)); + let (group, transform) = if let Some(reused) = reused { + reused + } else { + let g = groups.len(); + groups.push(Group { first: index, inverse: mat_inverse(&body.source_transform).unwrap_or(IDENTITY) }); + if unambiguous && mat_inverse(&body.source_transform).is_some() { + by_definition.entry(key).or_insert(g); + } + (g, IDENTITY) + }; + let keep = groups.get(group).is_some_and(|g| g.first == index); + items.push(Item { body, group, keep, transform }); + } + if items.is_empty() { + return Err(bad(CMD, "no visible bodies to export")); + } + let progress = Stage::new("dxf_meshes", items.len()); + let bytes = AtomicUsize::new(0); + let results = map_with_local_pools(&items, WORKERS, |item| { + progress.check().map_err(|e| KernelError::Failed(e.into()))?; + let mut mesh = item.body.body.reference_display_mesh_angular(tolerance, solidcraft_kernel::DISPLAY_ANGLE)?; + if mesh.triangles.is_empty() { + return Err(KernelError::Invalid(format!("{} has no exportable surface", item.body.name))); + } + let visibility = (0..mesh.edges.len()).map(|i| mesh.shaded_edge_visible(i)).collect(); + let triangles = mesh.triangles.len(); + let mesh = if item.keep { + // These are owned export buffers, not the document's cached mesh. Picking and + // shading data are unused by vector occlusion; edge choices were captured above. + mesh.normals = Vec::new(); + mesh.tri_face = Vec::new(); + mesh.seams = Vec::new(); + mesh.smooth_edges = Vec::new(); + mesh.sharp_edges = Vec::new(); + mesh.edge_faces = Vec::new(); + if !claim(&bytes, buffer_bytes(&mesh), MESH_BYTES) { + return Err(KernelError::Invalid("view exceeds export memory budget; hide bodies or increase tolerance".into())); + } + Some(Arc::new(mesh)) + } else { + None + }; + progress.advance(1).map_err(|e| KernelError::Failed(e.into()))?; + Ok(Prepared { mesh, visibility, triangles }) + }); + let mut prepared = results.into_iter().collect::>>()?; + let mut meshes: Vec>> = vec![None; groups.len()]; + for (item, p) in items.iter().zip(&mut prepared) { + if let Some(mesh) = p.mesh.take() + && let Some(slot) = meshes.get_mut(item.group) + { + *slot = Some(mesh); + } + } + let meshes = meshes.into_iter().map(|m| m.ok_or_else(|| bad(CMD, "missing export geometry"))).collect::>>()?; + let mut placements = Vec::with_capacity(items.len()); + let mut triangles = 0usize; + for (item, p) in items.iter().zip(prepared) { + if meshes.get(item.group).is_none_or(|m| m.edges.len() != p.visibility.len()) { + return Err(bad(CMD, "placed edge identity changed")); + } + triangles = triangles.saturating_add(p.triangles); + placements.push(Placement { mesh: item.group, transform: item.transform, visibility: p.visibility }); + } + Ok(Scene { meshes, placements, triangles, bytes: bytes.load(Ordering::Relaxed) }) +} + +pub(super) fn transformed_bounds(mesh: &Mesh, m: &Mat) -> Aabb3 { + let b = mesh.bounds(); + let mut out = Aabb3::EMPTY; + for x in [b.min.x, b.max.x] { + for y in [b.min.y, b.max.y] { + for z in [b.min.z, b.max.z] { + out.add(solidcraft_doc::apply_point(m, Vec3::new(x, y, z))); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn only_proper_rigid_placements_share_geometry() { + assert!(rigid(&solidcraft_doc::rigid(Vec3::new(1., 2., 3.), Vec3::ZERO, Vec3::Z, 0.7))); + let mut m = IDENTITY; + m[0][0] = -1.; + assert!(!rigid(&m)); + m[0][0] = 2.; + assert!(!rigid(&m)); + m[0][0] = f64::NAN; + assert!(!rigid(&m)); + } +} diff --git a/crates/engine/src/cmd/view_dxf_progress.rs b/crates/engine/src/cmd/view_dxf_progress.rs new file mode 100644 index 00000000..4c4adca7 --- /dev/null +++ b/crates/engine/src/cmd/view_dxf_progress.rs @@ -0,0 +1,35 @@ +//! Stage counters shared by the export worker and its bounded computation workers. +use std::sync::{Arc, Mutex}; + +pub(super) const CANCELLED: &str = "DXF export cancelled"; + +pub(super) struct Stage { + watch: Option>, + name: &'static str, + total: usize, + done: Mutex, +} + +impl Stage { + pub fn new(name: &'static str, total: usize) -> Self { + let stage = Self { watch: solidcraft_kernel::watch::current(), name, total: total.max(1), done: Mutex::new(0) }; + if let Some(watch) = &stage.watch { + watch.progress(name, 0, stage.total); + } + stage + } + pub fn check(&self) -> Result<(), &'static str> { + if self.watch.as_ref().is_some_and(|w| w.cancelled()) { Err(CANCELLED) } else { Ok(()) } + } + pub fn advance(&self, count: usize) -> Result<(), &'static str> { + self.check()?; + if let Some(watch) = &self.watch { + // Serialize reports as well as increments: eight workers must not report a + // smaller completed count after a larger one. + let mut done = self.done.lock().map_err(|_| "export progress unavailable")?; + *done = done.saturating_add(count).min(self.total); + watch.progress(self.name, *done, self.total); + } + self.check() + } +} diff --git a/crates/engine/src/cmd/view_dxf_projection.rs b/crates/engine/src/cmd/view_dxf_projection.rs index c496b2ac..bfb08025 100644 --- a/crates/engine/src/cmd/view_dxf_projection.rs +++ b/crates/engine/src/cmd/view_dxf_projection.rs @@ -1,10 +1,27 @@ //! 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}; +#[cfg(test)] +use solidcraft_geom::Mesh; +use solidcraft_geom::{Aabb3, Vec2, Vec3}; use solidcraft_io::{DxfEntity, DxfShape}; use solidcraft_render::Camera; use std::collections::BTreeMap; +#[path = "view_dxf_scene.rs"] +mod scene; + +pub(super) struct Drawing { + pub lines: Vec, + pub work: usize, + pub segments: usize, + pub projection_ms: f64, + pub occlusion_ms: f64, +} + +pub(super) fn drawing(meshes: &super::meshes::Scene, cam: &Camera) -> Result { + scene::drawing(meshes, cam) +} + /// 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 { @@ -50,6 +67,7 @@ pub(super) fn connect(entities: Vec) -> Vec { result } +#[derive(Clone, Debug)] pub(super) struct Line { pub a: Vec2, pub b: Vec2, @@ -73,15 +91,18 @@ 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 } +#[cfg(test)] struct Triangle { p: [Vec3; 3], bounds: Aabb3, } +#[cfg(test)] struct Tree { bounds: Aabb3, children: Option<(Box, Box)>, triangles: Vec, } +#[cfg(test)] impl Tree { fn new(mut triangles: Vec) -> Self { let b = triangles.iter().fold(Aabb3::EMPTY, |b, t| b.union(&t.bounds)); @@ -213,6 +234,7 @@ fn merge_lines(lines: Vec, eps: f64) -> Vec { /// B-rep edges plus curved silhouettes. Triangle adjacency is welded by position because /// different faces can have different vertex indices along their shared boundary. +#[cfg(test)] 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)) { @@ -241,7 +263,8 @@ fn edges(mesh: &Mesh, cam: &Camera, eps: f64) -> Vec<(Vec3, Vec3)> { result } -pub(super) fn drawing(meshes: &[Mesh], cam: &Camera) -> Result, &'static str> { +#[cfg(test)] +fn reference_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; diff --git a/crates/engine/src/cmd/view_dxf_scene.rs b/crates/engine/src/cmd/view_dxf_scene.rs new file mode 100644 index 00000000..849dcb56 --- /dev/null +++ b/crates/engine/src/cmd/view_dxf_scene.rs @@ -0,0 +1,627 @@ +//! Two levels of occlusion acceleration: an instance tree over projected bounds, and one +//! triangle hierarchy per shared mesh. Per-placement node bounds are small; projected +//! triangle coordinates are evaluated only in visited leaves. Covered line intervals prune +//! subsequent nodes and instances, including the entire query once coverage is complete. +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Instant; + +use solidcraft_doc::apply_point; +use solidcraft_geom::{Aabb3, Mesh, Vec3}; +use solidcraft_render::Camera; + +use super::super::meshes::{Placement, Scene, WORKERS, claim, transformed_bounds}; +use super::super::progress::Stage; +use super::{Drawing, Line, bounds, clip, key, merge_lines, occluded, overlap, xy}; + +const TOO_COMPLEX: &str = "view exceeds export work budget; hide bodies or increase tolerance"; +const MAX_LINES: usize = 1_000_000; +const MAX_SEGMENTS: usize = 4_000_000; +const MAX_SHARED_TRIANGLES: usize = 2_000_000; +const MAX_VIEW_NODES: usize = 256 * 1024 * 1024 / std::mem::size_of::(); +const WORK: usize = 200_000_000; + +fn parallel(items: &[T], f: impl Fn(&T) -> Result + Sync) -> Result, &'static str> { + solidcraft_kernel::map_on_threads(items, WORKERS, f).into_iter().map(|r| r.ok_or("export worker failed")?).collect() +} + +struct Node { + bounds: Aabb3, + start: usize, + end: usize, + children: Option<(usize, usize)>, +} +struct Bvh { + nodes: Vec, + ids: Vec, +} +impl Bvh { + fn new(boxes: &[Aabb3], flat: bool) -> Self { + let mut tree = Self { nodes: Vec::new(), ids: (0..boxes.len()).collect() }; + if !boxes.is_empty() { + tree.build(boxes, 0, boxes.len(), flat); + } + tree + } + fn build(&mut self, boxes: &[Aabb3], start: usize, end: usize, flat: bool) -> usize { + let b = self.ids.get(start..end).into_iter().flatten().filter_map(|i| boxes.get(*i)).fold(Aabb3::EMPTY, |b, t| b.union(t)); + let index = self.nodes.len(); + self.nodes.push(Node { bounds: b, start, end, children: None }); + if end - start > 8 { + let size = b.size(); + let axis = if !flat && size.z > size.x.max(size.y) { + 2 + } else if size.x > size.y { + 0 + } else { + 1 + }; + let value = |i: &usize| { + boxes.get(*i).map(|b| b.center()).map_or(0., |p| match axis { + 0 => p.x, + 1 => p.y, + _ => p.z, + }) + }; + let mid = start + (end - start) / 2; + if let Some(ids) = self.ids.get_mut(start..end) { + ids.select_nth_unstable_by(mid - start, |a, b| value(a).total_cmp(&value(b)).then_with(|| a.cmp(b))); + } + let left = self.build(boxes, start, mid, flat); + let right = self.build(boxes, mid, end, flat); + if let Some(node) = self.nodes.get_mut(index) { + node.children = Some((left, right)); + } + } + index + } +} + +struct Edge { + vertices: [u32; 2], + start: usize, + end: usize, +} +struct Side { + key: [[i64; 3]; 2], + vertices: [u32; 2], + triangle: usize, +} +struct Shared { + mesh: Arc, + tree: Bvh, + edges: Vec, + incident: Vec, +} +impl Shared { + fn new(mesh: &Arc, eps: f64, progress: &Stage) -> Result { + let mut boxes = Vec::with_capacity(mesh.triangles.len()); + let mut sides = Vec::new(); + for (i, indices) in mesh.triangles.iter().enumerate() { + if i.is_multiple_of(1024) { + progress.check()?; + } + let points = mesh.tri(indices).ok_or("invalid export triangle")?; + if points.iter().any(|p| !p.is_finite()) { + return Err("model contains invalid coordinates"); + } + boxes.push(bounds(points)); + for (a, b) in [(0, 1), (1, 2), (2, 0)] { + let (Some(pa), Some(pb), Some(ia), Some(ib)) = (points.get(a), points.get(b), indices.get(a), indices.get(b)) else { + return Err("invalid export edge"); + }; + let (ka, kb) = (key(*pa, eps), key(*pb, eps)); + if ka == kb { + continue; + } + let (key, vertices) = if ka < kb { ([ka, kb], [*ia, *ib]) } else { ([kb, ka], [*ib, *ia]) }; + sides.push(Side { key, vertices, triangle: i }); + } + } + let tree = Bvh::new(&boxes, false); + sides.sort_unstable_by(|a, b| a.key.cmp(&b.key).then_with(|| a.triangle.cmp(&b.triangle))); + let mut edges: Vec = Vec::new(); + let mut incident = Vec::with_capacity(sides.len()); + let mut previous = None; + for side in sides { + if previous != Some(side.key) { + edges.push(Edge { vertices: side.vertices, start: incident.len(), end: incident.len() }); + previous = Some(side.key); + } + incident.push(side.triangle); + if let Some(e) = edges.last_mut() { + e.end = incident.len(); + } + } + Ok(Self { mesh: mesh.clone(), tree, edges, incident }) + } +} + +struct View<'a> { + cam: &'a Camera, + right: Vec3, + up: Vec3, + back: Vec3, +} +impl<'a> View<'a> { + fn new(cam: &'a Camera) -> Self { + let (right, up, back) = cam.basis(); + Self { cam, right, up, back } + } + fn project(&self, p: Vec3) -> Result { + if !p.is_finite() { + return Err("model contains invalid coordinates"); + } + let d = p - self.cam.target; + let z = d.dot(self.back); + let projected = if self.cam.fov > 0. { + let w = self.cam.distance - z; + if w <= self.cam.distance * 1e-6 { + return Err("model crosses the camera eye plane; zoom out before exporting"); + } + Vec3::new(d.dot(self.right) * self.cam.distance / w, d.dot(self.up) * self.cam.distance / w, 1. / w) + } else { + Vec3::new(d.dot(self.right), d.dot(self.up), z) + }; + if !projected.is_finite() || projected.len() > 1e12 { + return Err("projected coordinates exceed the export range"); + } + Ok(projected) + } + fn triangle(&self, shared: &Shared, placed: &Placement, id: usize) -> Result<[Vec3; 3], &'static str> { + let indices = shared.mesh.triangles.get(id).ok_or("invalid export triangle")?; + let [a, b, c] = shared.mesh.tri(indices).ok_or("invalid export triangle")?; + Ok([ + self.project(apply_point(&placed.transform, a))?, + self.project(apply_point(&placed.transform, b))?, + self.project(apply_point(&placed.transform, c))?, + ]) + } +} + +struct Projected { + nodes: Vec, + segments: Vec<(Vec3, Vec3)>, +} +impl Projected { + fn new(shared: &Shared, placed: &Placement, view: &View<'_>, eps: f64, segments: &AtomicUsize, progress: &Stage) -> Result { + let mut nodes = vec![Aabb3::EMPTY; shared.tree.nodes.len()]; + // Exact projected leaf bounds avoid false near-eye errors from empty corners of a + // rotated model-space box. Interior bounds are just unions of the child bounds. + let mut front = vec![false; shared.mesh.triangles.len()]; + for (i, node) in shared.tree.nodes.iter().enumerate().rev() { + if i.is_multiple_of(1024) { + progress.check()?; + } + let mut b = Aabb3::EMPTY; + if let Some((left, right)) = node.children { + b = nodes.get(left).ok_or("invalid export tree")?.union(nodes.get(right).ok_or("invalid export tree")?); + } else { + for id in shared.tree.ids.get(node.start..node.end).ok_or("invalid export tree")? { + let p = view.triangle(shared, placed, *id)?; + b = b.union(&bounds(p)); + if let Some(f) = front.get_mut(*id) { + // A positive projective scale preserves the projected winding. + *f = super::cross(xy(p[1] - p[0]), xy(p[2] - p[0])) > 0.; + } + } + } + if let Some(slot) = nodes.get_mut(i) { + *slot = b; + } + } + let mut result = Vec::new(); + let mut add = |a: Vec3, b: Vec3| -> Result<(), &'static str> { + let (a, b) = (view.project(apply_point(&placed.transform, a))?, view.project(apply_point(&placed.transform, b))?); + if xy(a).dist(xy(b)) > eps { + if !claim(segments, 1, MAX_SEGMENTS) { + return Err(TOO_COMPLEX); + } + result.push((a, b)); + } + Ok(()) + }; + for (i, edge) in shared.mesh.edges.iter().enumerate() { + if !placed.visibility.get(i).copied().unwrap_or(false) { + continue; + } + for w in edge.windows(2) { + if let (Some(a), Some(b)) = (w.first(), w.get(1)) { + add(*a, *b)?; + } + } + } + for edge in &shared.edges { + let mut positive = false; + let mut negative = false; + for id in shared.incident.get(edge.start..edge.end).ok_or("invalid export adjacency")? { + if front.get(*id).copied().ok_or("invalid export adjacency")? { + positive = true; + } else { + negative = true; + } + } + if (positive && negative) || edge.end - edge.start == 1 { + let a = shared.mesh.positions.get(edge.vertices[0] as usize).ok_or("invalid silhouette")?; + let b = shared.mesh.positions.get(edge.vertices[1] as usize).ok_or("invalid silhouette")?; + add(*a, *b)?; + } + } + Ok(Self { nodes, segments: result }) + } +} + +/// A sorted union, updated as occluders are found. A node's line interval already covered by +/// nearer triangles cannot change the result and is skipped before visiting its children. +#[derive(Default)] +struct Coverage { + spans: Vec<(f64, f64)>, +} +impl Coverage { + fn contains(&self, lo: f64, hi: f64) -> bool { + self.spans.iter().any(|(a, b)| *a <= lo && *b >= hi) + } + fn add(&mut self, mut lo: f64, mut hi: f64) -> Result<(), &'static str> { + let first = self.spans.partition_point(|(_, b)| *b + 1e-9 < lo); + let mut end = first; + while let Some((a, b)) = self.spans.get(end) { + if *a > hi + 1e-9 { + break; + } + lo = lo.min(*a); + hi = hi.max(*b); + end += 1; + } + if first == end && self.spans.len() >= MAX_LINES / 2 { + return Err(TOO_COMPLEX); + } + self.spans.splice(first..end, [(lo, hi)]); + Ok(()) + } +} + +fn rectangle(a: Vec3, b: Vec3, box_: &Aabb3) -> Option<(f64, f64)> { + let (mut lo, mut hi) = (0., 1.); + for (v0, v1) in [ + (a.x - box_.min.x, b.x - box_.min.x), + (box_.max.x - a.x, box_.max.x - b.x), + (a.y - box_.min.y, b.y - box_.min.y), + (box_.max.y - a.y, box_.max.y - b.y), + ] { + if !clip(&mut lo, &mut hi, v0, v1) { + return None; + } + } + Some((lo, hi)) +} + +struct Budget<'a> { + global: &'a AtomicUsize, + progress: &'a Stage, + local: usize, + spent: usize, +} +impl<'a> Budget<'a> { + fn new(global: &'a AtomicUsize, progress: &'a Stage) -> Self { + Self { global, progress, local: 0, spent: 0 } + } + fn spend(&mut self) -> Result<(), &'static str> { + if self.local == 0 { + self.progress.check()?; + let mut old = self.global.load(Ordering::Relaxed); + loop { + let granted = old.min(4096); + if granted == 0 { + return Err(TOO_COMPLEX); + } + match self.global.compare_exchange_weak(old, old - granted, Ordering::Relaxed, Ordering::Relaxed) { + Ok(_) => { + self.local = granted; + break; + } + Err(current) => old = current, + } + } + } + self.local -= 1; + self.spent += 1; + Ok(()) + } +} + +struct Query<'a, 'b> { + shared: &'a [Shared], + scene: &'a Scene, + projected: &'a [Projected], + instances: &'a Bvh, + view: &'a View<'a>, + depth_eps: f64, + a: Vec3, + b: Vec3, + bounds: Aabb3, + covered: Coverage, + budget: &'b mut Budget<'a>, +} +impl Query<'_, '_> { + fn relevant(&self, b: &Aabb3) -> bool { + if b.max.z <= self.a.z.min(self.b.z) + self.depth_eps || !overlap(b, &self.bounds) { + return false; + } + rectangle(self.a, self.b, b).is_some_and(|(lo, hi)| !self.covered.contains(lo, hi)) + } + fn instance_node(&mut self, index: usize) -> Result<(), &'static str> { + self.budget.spend()?; + let node = self.instances.nodes.get(index).ok_or("invalid instance tree")?; + if !self.relevant(&node.bounds) { + return Ok(()); + } + if let Some((left, right)) = node.children { + let depth = |i: usize| self.instances.nodes.get(i).map_or(f64::NEG_INFINITY, |n| n.bounds.max.z); + let (near, far) = if depth(left) >= depth(right) { (left, right) } else { (right, left) }; + self.instance_node(near)?; + if !self.covered.contains(0., 1.) { + self.instance_node(far)?; + } + } else { + for id in self.instances.ids.get(node.start..node.end).ok_or("invalid instance tree")? { + self.mesh_node(*id, 0)?; + if self.covered.contains(0., 1.) { + break; + } + } + } + Ok(()) + } + fn mesh_node(&mut self, instance: usize, index: usize) -> Result<(), &'static str> { + self.budget.spend()?; + let placed = self.scene.placements.get(instance).ok_or("invalid instance")?; + let shared = self.shared.get(placed.mesh).ok_or("invalid shared geometry")?; + let projected = self.projected.get(instance).ok_or("invalid instance bounds")?; + let b = projected.nodes.get(index).ok_or("invalid mesh tree")?; + if !self.relevant(b) { + return Ok(()); + } + let node = shared.tree.nodes.get(index).ok_or("invalid mesh tree")?; + if let Some((left, right)) = node.children { + let depth = |i: usize| projected.nodes.get(i).map_or(f64::NEG_INFINITY, |b| b.max.z); + let (near, far) = if depth(left) >= depth(right) { (left, right) } else { (right, left) }; + self.mesh_node(instance, near)?; + if !self.covered.contains(0., 1.) { + self.mesh_node(instance, far)?; + } + } else { + for id in shared.tree.ids.get(node.start..node.end).ok_or("invalid mesh tree")? { + self.budget.spend()?; + let points = self.view.triangle(shared, placed, *id)?; + if let Some((lo, hi)) = occluded(self.a, self.b, points, self.depth_eps) { + self.covered.add(lo, hi)?; + if self.covered.contains(0., 1.) { + break; + } + } + } + } + Ok(()) + } +} + +pub(super) fn drawing(scene: &Scene, cam: &Camera) -> Result { + let start = Instant::now(); + let scale = scene + .placements + .iter() + .filter_map(|p| scene.meshes.get(p.mesh).map(|m| transformed_bounds(m, &p.transform))) + .fold(Aabb3::EMPTY, |b, p| b.union(&p)) + .diagonal() + .max(1.); + if !scale.is_finite() { + return Err("model contains invalid coordinates"); + } + let eps = scale * 1e-8; + // Indexed input can have many triangles but very few positions. Bound adjacency/sort + // scratch space as well as the retained mesh bytes before starting parallel builders. + let unique_triangles = scene.meshes.iter().try_fold(0usize, |n, m| n.checked_add(m.triangles.len())); + if unique_triangles.is_none_or(|n| n > MAX_SHARED_TRIANGLES) { + return Err("view exceeds export memory budget; hide bodies or increase tolerance"); + } + let progress = Stage::new("dxf_index", scene.meshes.len()); + let shared = parallel(&scene.meshes, |m| { + progress.check()?; + let shared = Shared::new(m, eps, &progress)?; + progress.advance(1)?; + Ok(shared) + })?; + let count = scene.placements.iter().try_fold(0usize, |n, p| n.checked_add(shared.get(p.mesh)?.tree.nodes.len())); + if count.is_none_or(|n| n > MAX_VIEW_NODES) { + return Err("view exceeds export memory budget; hide bodies or increase tolerance"); + } + let view = View::new(cam); + let count = AtomicUsize::new(0); + let progress = Stage::new("dxf_projection", scene.placements.len()); + let projected = parallel(&scene.placements, |p| { + progress.check()?; + let projected = Projected::new(shared.get(p.mesh).ok_or("missing shared geometry")?, p, &view, eps, &count, &progress)?; + progress.advance(1)?; + Ok(projected) + })?; + let boxes = projected.iter().map(|p| p.nodes.first().copied().unwrap_or(Aabb3::EMPTY)).collect::>(); + let mut instances = Bvh::new(&boxes, true); + // The view is fixed for the entire drawing. Sort the at-most-eight members of each + // instance leaf once, rather than allocating/sorting on every line query. + for node in instances.nodes.iter().filter(|n| n.children.is_none()) { + if let Some(ids) = instances.ids.get_mut(node.start..node.end) { + ids.sort_unstable_by(|a, b| { + boxes + .get(*b) + .map_or(f64::NEG_INFINITY, |b| b.max.z) + .total_cmp(&boxes.get(*a).map_or(f64::NEG_INFINITY, |b| b.max.z)) + .then_with(|| a.cmp(b)) + }); + } + } + let segment_count = count.load(Ordering::Relaxed); + let projection_ms = start.elapsed().as_secs_f64() * 1000.; + let start = Instant::now(); + let depth_eps = if cam.fov > 0. { 1e-8 / cam.distance } else { eps }; + let global = AtomicUsize::new(WORK); + let output = AtomicUsize::new(0); + let chunks = projected.iter().flat_map(|p| p.segments.chunks(128)).collect::>(); + let progress = Stage::new("dxf_occlusion", segment_count); + let results = parallel(&chunks, |chunk| { + progress.check()?; + let mut budget = Budget::new(&global, &progress); + let mut lines = Vec::new(); + for &(a, b) in *chunk { + let mut query = Query { + shared: &shared, + scene, + projected: &projected, + instances: &instances, + view: &view, + depth_eps, + a, + b, + bounds: bounds([a, b]), + covered: Coverage::default(), + budget: &mut budget, + }; + query.instance_node(0)?; + let mut cursor = 0.; + let mut emit = |lo: f64, hi: f64, hidden: bool| -> Result<(), &'static str> { + let (p, q) = (xy(a + (b - a) * lo), xy(a + (b - a) * hi)); + if p.dist(q) > eps { + if !claim(&output, 1, MAX_LINES) { + return Err(TOO_COMPLEX); + } + lines.push(Line { a: p, b: q, hidden }); + } + Ok(()) + }; + for (lo, hi) in query.covered.spans { + emit(cursor, lo, false)?; + emit(lo, hi, true)?; + cursor = hi; + } + emit(cursor, 1., false)?; + } + progress.advance(chunk.len())?; + Ok((lines, budget.spent)) + })?; + let work = results.iter().map(|(_, n)| *n).sum(); + let lines = merge_lines(results.into_iter().flat_map(|(lines, _)| lines).collect(), eps); + progress.check()?; + Ok(Drawing { lines, work, segments: segment_count, projection_ms, occlusion_ms: start.elapsed().as_secs_f64() * 1000. }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn coverage_handles_disjoint_spans_and_early_complete_coverage() { + let mut c = Coverage::default(); + c.add(0.4, 0.6).unwrap(); + c.add(0., 0.2).unwrap(); + assert!(c.contains(0.05, 0.15)); + assert!(!c.contains(0., 1.)); + c.add(0.2, 0.9).unwrap(); + c.add(0.8, 1.).unwrap(); + assert_eq!(c.spans, vec![(0., 1.)]); + } + + fn box_mesh() -> Mesh { + let mut mesh = Mesh::default(); + mesh.positions = vec![ + Vec3::new(0., 0., 0.), + Vec3::new(8., 0., 0.), + Vec3::new(8., 6., 0.), + Vec3::new(0., 6., 0.), + Vec3::new(0., 0., 3.), + Vec3::new(8., 0., 3.), + Vec3::new(8., 6., 3.), + Vec3::new(0., 6., 3.), + ]; + mesh.triangles = + vec![[0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5], [2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7]]; + mesh.edges = [(0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7)] + .into_iter() + .map(|(a, b)| vec![mesh.positions[a], mesh.positions[b]]) + .collect(); + mesh + } + + fn canonical(lines: Vec) -> Vec<([i64; 2], [i64; 2], bool)> { + let k = |p: solidcraft_geom::Vec2| [(p.x * 1e6).round() as i64, (p.y * 1e6).round() as i64]; + let mut result = lines + .into_iter() + .map(|l| { + let (a, b) = (k(l.a), k(l.b)); + if a < b { (a, b, l.hidden) } else { (b, a, l.hidden) } + }) + .collect::>(); + result.sort_unstable(); + result + } + + #[test] + fn shared_rotated_instances_match_flat_occlusion_in_orthographic_and_perspective_views() { + use std::sync::Arc; + let mesh = box_mesh(); + let mut placements = Vec::new(); + let mut flat = Vec::new(); + for (offset, angle) in [(Vec3::ZERO, 0.), (Vec3::new(3., 2., 4.), 0.3), (Vec3::new(2., 1., -4.), -0.6)] { + let transform = solidcraft_doc::rigid(offset, Vec3::ZERO, Vec3::Z, angle); + placements.push(Placement { mesh: 0, transform, visibility: vec![true; mesh.edges.len()] }); + let mut placed = mesh.clone(); + placed.positions = placed.positions.iter().map(|p| apply_point(&transform, *p)).collect(); + placed.edges = placed.edges.iter().map(|e| e.iter().map(|p| apply_point(&transform, *p)).collect()).collect(); + flat.push(placed); + } + let scene = Scene { meshes: vec![Arc::new(mesh)], placements, triangles: 36, bytes: 0 }; + for (yaw, pitch, roll) in [(0., std::f64::consts::FRAC_PI_2, 0.), (0.7, 0.4, 0.2), (0., 0., 0.)] { + for fov in [0., 0.6] { + let cam = Camera { target: Vec3::new(4., 3., 1.), yaw, pitch, roll, fov, distance: 100., ..Default::default() }; + let expected = super::super::reference_drawing(&flat, &cam).unwrap(); + let actual = drawing(&scene, &cam).unwrap(); + assert_eq!(canonical(actual.lines), canonical(expected), "yaw={yaw}, pitch={pitch}, fov={fov}"); + } + } + } + + #[test] + fn repeated_surface_over_one_million_triangles_keeps_one_mesh_and_exports_all_placements() { + use solidcraft_render::StandardView; + use std::sync::Arc; + let mut mesh = Mesh::default(); + for y in 0..=100 { + for x in 0..=100 { + mesh.positions.push(Vec3::new(x as f64 / 10., y as f64 / 10., 0.)); + } + } + for y in 0..100u32 { + for x in 0..100u32 { + let a = y * 101 + x; + mesh.triangles.extend([[a, a + 1, a + 102], [a, a + 102, a + 101]]); + } + } + mesh.edges = vec![ + vec![Vec3::ZERO, Vec3::new(10., 0., 0.)], + vec![Vec3::new(10., 0., 0.), Vec3::new(10., 10., 0.)], + vec![Vec3::new(10., 10., 0.), Vec3::new(0., 10., 0.)], + vec![Vec3::new(0., 10., 0.), Vec3::ZERO], + ]; + let placements = (0..60) + .map(|i| Placement { + mesh: 0, + transform: solidcraft_doc::rigid(Vec3::new((i % 10) as f64 * 20., (i / 10) as f64 * 20., 0.), Vec3::ZERO, Vec3::Z, 0.), + visibility: vec![true; 4], + }) + .collect(); + let scene = Scene { meshes: vec![Arc::new(mesh)], placements, triangles: 1_200_000, bytes: 0 }; + let mut cam = Camera { target: Vec3::new(95., 55., 0.), fov: 0., distance: 300., ..Default::default() }; + cam.set_view(StandardView::Top); + let result = drawing(&scene, &cam).unwrap(); + assert_eq!(scene.meshes.len(), 1); + assert_eq!(result.lines.len(), 240); + assert!(result.lines.iter().all(|l| !l.hidden)); + } +} diff --git a/crates/engine/src/cmd/view_dxf_tests.rs b/crates/engine/src/cmd/view_dxf_tests.rs index 3b6242a6..9b8c37e4 100644 --- a/crates/engine/src/cmd/view_dxf_tests.rs +++ b/crates/engine/src/cmd/view_dxf_tests.rs @@ -21,6 +21,49 @@ fn export(s: &mut Session, c: Camera, hidden: &str) -> Value { run(s, CMD, json!({"camera":c,"hidden":hidden})) } +#[test] +fn view_dxf_cancellation_at_each_stage_preserves_the_destination_and_document() { + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }; + struct CancelAt { + stage: &'static str, + cancelled: AtomicBool, + reached: AtomicBool, + } + impl solidcraft_kernel::watch::Watch for CancelAt { + fn cancelled(&self) -> bool { + self.cancelled.load(Ordering::Relaxed) + } + fn progress(&self, stage: &'static str, done: usize, _total: usize) { + if stage == self.stage && (done > 0 || stage == "dxf_write") { + self.reached.store(true, Ordering::Relaxed); + self.cancelled.store(true, Ordering::Relaxed); + } + } + } + let dir = std::env::temp_dir().join(format!("solidcraft-dxf-cancel-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("drawing.dxf"); + let mut s = sample(); + let doc = s.doc.clone(); + let undo = s.undo.len(); + for stage in ["dxf_meshes", "dxf_index", "dxf_projection", "dxf_occlusion", "dxf_write"] { + std::fs::write(&path, b"previous drawing").unwrap(); + let watch = Arc::new(CancelAt { stage, cancelled: AtomicBool::new(false), reached: AtomicBool::new(false) }); + let result = solidcraft_kernel::watch::watching(watch.clone(), || { + s.execute(CMD, &json!({"camera":camera(StandardView::Top),"path":path.to_string_lossy()})) + }); + assert!(result.unwrap_err().to_string().contains("cancelled"), "{stage}"); + assert!(watch.reached.load(Ordering::Relaxed), "{stage}"); + assert_eq!(std::fs::read(&path).unwrap(), b"previous drawing", "{stage}"); + assert!(Arc::ptr_eq(&doc, &s.doc)); + assert_eq!(s.undo.len(), undo); + } + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn view_dxf_blind_back_hole_is_omitted_or_dashed_and_front_hole_is_visible() { let mut s = sample(); @@ -100,3 +143,32 @@ fn view_dxf_rejects_invalid_options_and_near_eye_geometry_without_panicking() { c.fov = 4.; assert!(s.execute(CMD, &json!({"camera":c})).is_err()); } + +#[test] +fn view_dxf_keeps_every_occurrence_while_sharing_geometry() { + let mut s = Session::default(); + let component = run(&mut s, "component.create", json!({"name":"Repeated part"}))["component"].as_u64().unwrap(); + run(&mut s, "solid.box", json!({"length":8,"width":6,"height":3})); + for x in [15., 30.] { + run(&mut s, "occurrence.copy", json!({"component":component,"translate":[x,0,0]})); + } + run(&mut s, "component.activate", json!({"component":0})); + let doc = s.doc.clone(); + let result = export(&mut s, camera(StandardView::Top), "dashed"); + assert_eq!(result["stats"]["bodies"], 3); + assert_eq!(result["stats"]["shared_meshes"], 1); + assert_eq!(result["stats"]["worker_limit"], meshes::WORKERS); + assert!(result["hidden"].as_u64().unwrap() > 0); + let lines = solidcraft_io::read_dxf(result["dxf"].as_str().unwrap()).unwrap(); + let mut centers = std::collections::BTreeSet::new(); + for line in lines { + if let solidcraft_io::Geom2::Line(a, b) = line { + // Each translated copy has a visible top perimeter, including the offscreen one. + if (a.y - b.y).abs() > 5.9 { + centers.insert(((a.x + 20.) / 15.).floor() as i64); + } + } + } + assert!(centers.len() >= 3, "{centers:?}"); + assert!(std::sync::Arc::ptr_eq(&doc, &s.doc)); +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index b4907752..a87fa2d6 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -85,7 +85,7 @@ mod outline; #[cfg(test)] mod outline_tests; mod par; -pub use par::{for_each_on_threads, map_on_threads}; +pub use par::{for_each_on_threads, map_on_threads, map_with_local_pools}; #[cfg(test)] mod pinch_tests; mod planar_faces; diff --git a/crates/kernel/src/par.rs b/crates/kernel/src/par.rs index 5e08be76..b18ea88b 100644 --- a/crates/kernel/src/par.rs +++ b/crates/kernel/src/par.rs @@ -33,6 +33,37 @@ pub fn for_each_on_threads(items: &[T], max_threads: usize, f: impl Fn( let _ = map_on_threads(items, max_threads, f); } +/// Independent kernel jobs, with one computation lane per native dispatcher. Each dispatcher +/// owns a single-thread Rayon pool: nested triangulation neither queues behind a common +/// one-thread pool nor multiplies the requested concurrency. No second body can be stolen by +/// a worker while it fills a shared lazy mesh cell. The browser runs serially. +pub fn map_with_local_pools(items: &[T], max_threads: usize, f: impl Fn(&T) -> Result + Sync) -> Vec> { + #[cfg(not(target_arch = "wasm32"))] + let one = |item: &T| { + use std::cell::OnceCell; + thread_local! { + static POOL: OnceCell = const { OnceCell::new() }; + } + POOL.with(|cell| { + if cell.get().is_none() { + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(1) + .build() + .map_err(|e| crate::KernelError::Failed(format!("cannot start mesh worker: {e}")))?; + let _ = cell.set(pool); + } + let pool = cell.get().ok_or_else(|| crate::KernelError::Failed("missing mesh worker".into()))?; + pool.install(|| guard("mesh worker", || f(item))) + }) + }; + #[cfg(target_arch = "wasm32")] + let one = |item: &T| guard("mesh worker", || f(item)); + map_on_threads(items, max_threads, one) + .into_iter() + .map(|r| r.unwrap_or_else(|| Err(crate::KernelError::Internal("mesh worker failed".into())))) + .collect() +} + /// `f` over `items` as [`for_each_on_threads`] runs it, results in item order (`None` where an /// item panicked). pub fn map_on_threads(items: &[T], max_threads: usize, f: impl Fn(&T) -> R + Sync) -> Vec> { @@ -82,6 +113,19 @@ mod tests { use super::*; use crate::KernelError; + #[test] + fn local_pools_keep_order_and_errors_and_bound_nested_parallelism() { + let items: Vec = (0..32).collect(); + let result = map_with_local_pools(&items, 8, |i| { + #[cfg(not(target_arch = "wasm32"))] + assert_eq!(rayon::current_num_threads(), 1); + if *i == 7 { Err(KernelError::Invalid("seven".into())) } else { Ok(*i * 2) } + }); + for (i, r) in result.iter().enumerate() { + assert_eq!(r.as_ref().ok().copied(), (i != 7).then_some(i * 2)); + } + } + #[test] fn each_item_once_on_threads() { let items: Vec = (0..1000).collect(); diff --git a/crates/kernel/src/watch.rs b/crates/kernel/src/watch.rs index 840988c2..97f96fb9 100644 --- a/crates/kernel/src/watch.rs +++ b/crates/kernel/src/watch.rs @@ -34,7 +34,7 @@ pub fn watching(watch: Arc, f: impl FnOnce() -> R) -> R { } /// The watch over this thread's work, to hand to the worker threads it starts. -pub(crate) fn current() -> Option> { +pub fn current() -> Option> { CURRENT.with(|c| c.borrow().clone()) } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1385ca2..d790e712 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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 { diff --git a/crates/ui-egui/src/dialogs_view_dxf.rs b/crates/ui-egui/src/dialogs_view_dxf.rs index cbf0222b..7a7a757c 100644 --- a/crates/ui-egui/src/dialogs_view_dxf.rs +++ b/crates/ui-egui/src/dialogs_view_dxf.rs @@ -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)> { - (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)); + } + } } diff --git a/crates/ui-egui/src/file_load.rs b/crates/ui-egui/src/file_load.rs index bfe3b711..f9dc3cca 100644 --- a/crates/ui-egui/src/file_load.rs +++ b/crates/ui-egui/src/file_load.rs @@ -29,6 +29,8 @@ pub enum Kind { Insert, /// Open a design an earlier run autosaved before it closed. Recover { id: String, dir: Option }, + /// 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) { @@ -99,6 +102,14 @@ struct Running { staged: Option, } +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)] { diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index c5288792..989c9f2a 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -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, @@ -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(), diff --git a/docs/view-dxf.md b/docs/view-dxf.md index 9fa3e3fd..17ef1bf0 100644 --- a/docs/view-dxf.md +++ b/docs/view-dxf.md @@ -1,8 +1,22 @@ # 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でエクスポート…**. +export them as dashed lines, set **Curve tolerance**, then choose **Export DXF…** and a +destination. Both settings are remembered. The Japanese menu is +**ファイル → 現在の表示をDXFでエクスポート…**. + +On desktop, the export runs on a background worker while the window continues repainting +and responding to its title-bar controls. A progress card shows mesh preparation, surface +preparation, projection, hidden-line removal and writing, with a count and progress bar for +the current stage. **Cancel** stops at the next mesh or traversal checkpoint; a single +meshing call or sort may need to finish first. The model waits during the export and remains +unchanged on success, failure and cancellation. Individual control-channel +`file.export_view_dxf` commands use the same worker. +The browser's export still runs synchronously on its single thread. + +An export replaces its destination atomically only after the complete drawing is ready. +Cancellation that takes effect before the final write preserves any previous file. Once +that atomic write starts, it finishes; a late Cancel reports the completed save. 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 @@ -15,7 +29,9 @@ Coordinates are 2D, Z=0, in millimetres. Orthographic views retain 1:1 dimension 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. +model-space chord tolerance of 0.01 mm. The setting accepts 0.0001–1 mm; smaller values +produce finer curves and generally need more time and memory. This is not an exact analytic +curve export or a tolerance guarantee for the original imported surfaces. 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 @@ -29,6 +45,18 @@ are excluded; curved-body silhouettes are included. Very complex drawings fail w error rather than returning an incomplete export. If geometry crosses the perspective camera's eye plane, zoom out before exporting. +Repeated component placements share their export mesh and triangle hierarchy. Each placement +keeps its own edge choices, position and orientation; no bodies are fused or omitted to reduce +the workload. A hierarchy over placed parts rejects unrelated occluders before searching a +part's triangles. Already hidden intervals are skipped, and a fully hidden line stops searching. +Desktop mesh preparation and independent line queries use at most eight computation workers; +the browser runs serially. Export memory, candidate lines, output lines and total occlusion +work remain bounded, and exceeding a budget returns an error without writing a partial drawing. + 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. +The result also includes `stats`: placed body and shared mesh counts, source triangle count, +retained mesh bytes, worker limit, candidate segment count, occlusion work, and preparation, +projection and occlusion times in milliseconds. These timings exclude STEP import and DXF +serialization; the worker limit is a maximum, not a claim that every worker was busy.