From 2da3640b45522e971365a78345ea41a39089858e Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 6 May 2026 23:04:53 +0300 Subject: [PATCH] perf: build hatch/image/mesh caches off the UI thread Parsing + DerivedCaches construction now runs entirely in a dedicated OS thread inside open_path(). FileOpened handler only assigns the pre-built caches, keeping the UI thread free during file load. - Add build_derived_caches(): parallel hatch + mesh tessellation via rayon - Extend Message::FileOpened / open_path return type with DerivedCaches - Drop populate_*_from_document() calls from FileOpened handler --- src/app/mod.rs | 2 +- src/app/update.rs | 12 +-- src/io/mod.rs | 20 +++-- src/scene/mod.rs | 189 ++++++++++++++++++++++++++++++++++++---------- 4 files changed, 172 insertions(+), 51 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index e613d41e9..2d82e411d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -212,7 +212,7 @@ pub enum DsField { pub enum Message { Tick(Instant), OpenFile, - FileOpened(Result<(String, PathBuf, CadDocument), String>), + FileOpened(Result<(String, PathBuf, CadDocument, crate::scene::DerivedCaches), String>), SaveFile, SaveAs, // ── Custom Save-As dialog ───────────────────────────────────────────── diff --git a/src/app/update.rs b/src/app/update.rs index 8a77ad64e..07c9219b2 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -35,7 +35,7 @@ impl H7CAD { Message::OpenFile => Task::perform(crate::io::pick_and_open(), Message::FileOpened), - Message::FileOpened(Ok((name, path, doc))) => { + Message::FileOpened(Ok((name, path, doc, caches))) => { let entity_count = doc.entities().count(); self.command_line .push_output(&format!("Opened \"{name}\" — {entity_count} entities")); @@ -87,10 +87,12 @@ impl H7CAD { } } - self.tabs[i].scene.compute_and_set_world_offset(); - self.tabs[i].scene.populate_hatches_from_document(); - self.tabs[i].scene.populate_images_from_document(); - self.tabs[i].scene.populate_meshes_from_document(); + // Caches were built on the background thread inside open_path(). + self.tabs[i].scene.world_offset = caches.world_offset; + self.tabs[i].scene.local_extent_max = caches.local_extent_max; + self.tabs[i].scene.hatches = caches.hatches; + self.tabs[i].scene.images = caches.images; + self.tabs[i].scene.meshes = caches.meshes; self.tabs[i].scene.selected = std::collections::HashSet::new(); self.tabs[i].scene.preview_wires = vec![]; self.tabs[i].scene.current_layout = "Model".to_string(); diff --git a/src/io/mod.rs b/src/io/mod.rs index 7f486afa4..5471b2a30 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -14,13 +14,14 @@ pub mod xref; use acadrust::entities::{Dimension, EntityType}; use acadrust::io::dwg::DwgReader; use acadrust::{CadDocument, DwgWriter, DxfReader, DxfWriter}; +use crate::scene::DerivedCaches; use std::path::{Path, PathBuf}; // ── Open ────────────────────────────────────────────────────────────────── /// Show a file-open dialog and load the selected DWG or DXF file. -/// Returns `(filename, path, document)` or an error string. -pub async fn pick_and_open() -> Result<(String, PathBuf, CadDocument), String> { +/// Returns `(filename, path, document, caches)` or an error string. +pub async fn pick_and_open() -> Result<(String, PathBuf, CadDocument, DerivedCaches), String> { let handle = rfd::AsyncFileDialog::new() .set_title("Open CAD file") .add_filter("CAD Files", &["dwg", "dxf", "DWG", "DXF"]) @@ -40,13 +41,22 @@ pub async fn pick_and_open() -> Result<(String, PathBuf, CadDocument), String> { } /// Load a CAD file from a known path (used by recent files). -pub async fn open_path(path: PathBuf) -> Result<(String, PathBuf, CadDocument), String> { +/// Parsing and cache building run on a dedicated OS thread so the async +/// executor stays free for rendering during the load. +pub async fn open_path(path: PathBuf) -> Result<(String, PathBuf, CadDocument, DerivedCaches), String> { let name = path .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| "unknown".into()); - let doc = load_file(&path)?; - Ok((name, path, doc)) + let path2 = path.clone(); + let (doc, caches) = std::thread::spawn(move || -> Result<_, String> { + let doc = load_file(&path2)?; + let caches = crate::scene::build_derived_caches(&doc); + Ok((doc, caches)) + }) + .join() + .map_err(|_| "parser thread panicked".to_string())??; + Ok((name, path, doc, caches)) } /// Load a DWG or DXF file directly from a path (auto-detect by extension). diff --git a/src/scene/mod.rs b/src/scene/mod.rs index abe247d97..b9b7bd793 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -60,6 +60,109 @@ use std::sync::Arc; /// GPU Pipeline to skip re-uploading geometry when switching tabs. static GEOMETRY_EPOCH: AtomicU64 = AtomicU64::new(1); +/// Pre-built entity caches returned by [`build_derived_caches`]. +/// Produced in the file-load background task so the UI thread only assigns. +#[derive(Debug, Clone)] +pub struct DerivedCaches { + pub world_offset: [f64; 3], + pub local_extent_max: f32, + pub hatches: HashMap, + pub images: HashMap, + pub meshes: HashMap, +} + +/// Build hatch / image / mesh caches from a document without needing `&mut Scene`. +/// Intended to run on a background thread during file load. +pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches { + // world_offset + let h = &doc.header; + let (min, max) = (h.model_space_extents_min, h.model_space_extents_max); + let valid = min.x < max.x && min.y < max.y; + let (world_offset, local_extent_max) = if valid { + let offset = [(min.x + max.x) * 0.5, (min.y + max.y) * 0.5, (min.z + max.z) * 0.5]; + let hw = ((max.x - min.x) * 0.5) as f32; + let hh = ((max.y - min.y) * 0.5) as f32; + let hz = ((max.z - min.z) * 0.5).max(1.0) as f32; + (offset, hw.max(hh).max(hz) * 10.0) + } else { + ([0.0; 3], 1e9_f32) + }; + + // model-space block handle (same logic as Scene::model_space_block_handle) + let model_block = doc.objects.values().find_map(|obj| { + if let acadrust::objects::ObjectType::Layout(l) = obj { + if l.name == "Model" && !l.block_record.is_null() { Some(l.block_record) } else { None } + } else { None } + }).unwrap_or_else(|| { + doc.block_records.get("*Model_Space").map(|br| br.handle).unwrap_or(Handle::NULL) + }); + + use rayon::prelude::*; + + // hatches + let hatch_entries: Vec<(Handle, EntityType)> = doc.entities() + .filter_map(|e| match e { + EntityType::Hatch(h2) => Some((h2.common.handle, e.clone())), + EntityType::Solid(s) => Some((s.common.handle, e.clone())), + _ => None, + }) + .collect(); + let hatches: HashMap = hatch_entries + .into_par_iter() + .filter_map(|(handle, kind)| { + let owner = kind.common().owner_handle; + let offset = if owner == model_block { world_offset } else { [0.0; 3] }; + let model = match &kind { + EntityType::Hatch(dxf) => { + let color = tessellate::aci_to_rgba(&dxf.common.color); + Scene::hatch_model_from_dxf(dxf, color, offset) + } + EntityType::Solid(solid) => { + let color = tessellate::aci_to_rgba(&solid.common.color); + Some(Scene::solid_hatch_model(solid, color, offset)) + } + _ => None, + }; + model.map(|m| (handle, m)) + }) + .collect(); + + // images + let images: HashMap = doc.entities() + .filter_map(|e| { + if let EntityType::RasterImage(img) = e { + ImageModel::from_raster_image(img).map(|m| (img.common.handle, m)) + } else { + None + } + }) + .collect(); + + // meshes (parallel tessellation) + let mesh_entries: Vec<(Handle, EntityType)> = doc.entities() + .filter_map(|e| match e { + EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) => + Some((e.common().handle, e.clone())), + _ => None, + }) + .collect(); + let meshes: HashMap = mesh_entries + .into_par_iter() + .filter_map(|(handle, entity)| { + let color = tessellate::aci_to_rgba(&entity.common().color); + let model = match &entity { + EntityType::Solid3D(s) => solid3d_tess::tessellate_solid3d(s, color), + EntityType::Region(r) => solid3d_tess::tessellate_region(r, color), + EntityType::Body(b) => solid3d_tess::tessellate_body(b, color), + _ => None, + }; + model.map(|m| (handle, m)) + }) + .collect(); + + DerivedCaches { world_offset, local_extent_max, hatches, images, meshes } +} + pub struct Scene { pub camera: Rc>, pub selection: Rc>, @@ -2043,6 +2146,7 @@ impl Scene { self.hatches.clear(); let model_block = self.model_space_block_handle(); + let world_offset = self.world_offset; let entries: Vec<(Handle, EntityType)> = self .document @@ -2054,26 +2158,29 @@ impl Scene { }) .collect(); - for (handle, kind) in entries { - // Paper-space entities live in sheet coordinates — world_offset must not - // be applied to them. Only model-space entities need the shift. - let owner = kind.common().owner_handle; - let offset = if owner == model_block { self.world_offset } else { [0.0; 3] }; - let model = match &kind { - EntityType::Hatch(dxf) => { - let color = tessellate::aci_to_rgba(&dxf.common.color); - Self::hatch_model_from_dxf(dxf, color, offset) - } - EntityType::Solid(solid) => { - let color = tessellate::aci_to_rgba(&solid.common.color); - Some(Self::solid_hatch_model(solid, color, offset)) - } - _ => None, - }; - if let Some(m) = model { - self.hatches.insert(handle, m); - } - } + use rayon::prelude::*; + self.hatches = entries + .into_par_iter() + .filter_map(|(handle, kind)| { + // Paper-space entities live in sheet coordinates — world_offset must not + // be applied to them. Only model-space entities need the shift. + let owner = kind.common().owner_handle; + let offset = if owner == model_block { world_offset } else { [0.0; 3] }; + let model = match &kind { + EntityType::Hatch(dxf) => { + let color = tessellate::aci_to_rgba(&dxf.common.color); + Self::hatch_model_from_dxf(dxf, color, offset) + } + EntityType::Solid(solid) => { + let color = tessellate::aci_to_rgba(&solid.common.color); + Some(Self::solid_hatch_model(solid, color, offset)) + } + _ => None, + }; + model.map(|m| (handle, m)) + }) + .collect(); + self.bump_geometry(); } @@ -2084,32 +2191,34 @@ impl Scene { /// `Solid3D` entity is represented in the mesh cache. pub fn populate_meshes_from_document(&mut self) { self.meshes.clear(); - // Collect all ACIS-bearing entities: Solid3D, Region, Body. - let entries: Vec<(Handle, EntityType)> = self + // Collect all ACIS-bearing entities with their color resolved now, + // so the parallel phase only sees owned data. + let entries: Vec<(Handle, EntityType, [f32; 4])> = self .document .entities() .filter_map(|e| match e { - EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) => - Some((e.common().handle, e.clone())), + EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) => { + let color = tessellate::aci_to_rgba(&e.common().color); + Some((e.common().handle, e.clone(), color)) + } _ => None, }) .collect(); - for (handle, entity) in entries { - let color = if let Some(e) = self.document.get_entity(handle) { - tessellate::aci_to_rgba(&e.common().color) - } else { - [0.7, 0.7, 0.7, 1.0] - }; - let model = match &entity { - EntityType::Solid3D(s) => solid3d_tess::tessellate_solid3d(s, color), - EntityType::Region(r) => solid3d_tess::tessellate_region(r, color), - EntityType::Body(b) => solid3d_tess::tessellate_body(b, color), - _ => None, - }; - if let Some(m) = model { - self.meshes.insert(handle, m); - } - } + + use rayon::prelude::*; + self.meshes = entries + .into_par_iter() + .filter_map(|(handle, entity, color)| { + let model = match &entity { + EntityType::Solid3D(s) => solid3d_tess::tessellate_solid3d(s, color), + EntityType::Region(r) => solid3d_tess::tessellate_region(r, color), + EntityType::Body(b) => solid3d_tess::tessellate_body(b, color), + _ => None, + }; + model.map(|m| (handle, m)) + }) + .collect(); + self.bump_geometry(); }