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
This commit is contained in:
Hakan Seven 2026-05-06 23:04:53 +03:00
commit 2da3640b45
4 changed files with 172 additions and 51 deletions

View file

@ -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 ─────────────────────────────────────────────

View file

@ -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();

View file

@ -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).

View file

@ -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<Handle, HatchModel>,
pub images: HashMap<Handle, ImageModel>,
pub meshes: HashMap<Handle, MeshModel>,
}
/// 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<Handle, HatchModel> = 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<Handle, ImageModel> = 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<Handle, MeshModel> = 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<RefCell<Camera>>,
pub selection: Rc<RefCell<SelectionState>>,
@ -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();
}