diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 1c7cbb6..e6051a6 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -178,7 +178,9 @@ pub(crate) fn zoom(s: &mut Session, p: &Value) -> Result { } _ => return Err(bad("zoom", "unknown mode")), } - s.touch(); + // No `touch`: a zoom changes the view, not the drawing. The canvas follows the view by + // itself and regenerates in the background (AutoCAD redraws instead of regenerating); + // zooming an unlocked viewport changes the drawing, which shows on its own. Ok(Value::Null) } @@ -385,7 +387,6 @@ impl ZoomM { let extents = if ext.is_empty() { s.zoom_limits()? } else { ext }; let all = extents.union(&DynBox::rect(frame.center, frame.height, aspect)); fit(s, all.expand(all.height().max(all.width()) * 0.05))?; - s.touch(); self.stage = ZoomStage::Dynamic(DynBox { screen, frame, extents, center: frame.center, height: frame.height, aspect, sizing: false }); Ok(Step::Continue) } @@ -440,7 +441,6 @@ impl Interactive for ZoomM { st.view_history.pop(); } st.set_zoom_frame(View { center: b.center, height: b.height }); - s.touch(); return Ok(Step::Done); } (ZoomStage::Dynamic(mut b), Input::Point(p)) => { diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index a9f59e5..1423e7a 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -409,6 +409,7 @@ fn sheet(d: &Drawing, space: &Space, o: &PdfOptions, links_to: &[(Handle, String plot_style_table, view: None, skip_viewport: None, + cancel: None, }; let k = unit_mm * PT_PER_MM; let window = o.window.filter(|w| matches!(space, Space::Model) && !w.is_empty()); diff --git a/crates/render/src/hatch.rs b/crates/render/src/hatch.rs index ed397d0..e8d7537 100644 --- a/crates/render/src/hatch.rs +++ b/crates/render/src/hatch.rs @@ -13,8 +13,14 @@ use cadcraft_color::{Color, Rgb}; use cadcraft_doc::library::{PatternLine, pattern_for}; use cadcraft_doc::{Drawing, Entity, Gradient, Hatch}; use cadcraft_geom::{Bounds2, Polyline, Vec2, point_in_polygon}; +use std::sync::atomic::{AtomicBool, Ordering}; /// Draw a hatch entity: `rgb` is its resolved colour, `tol` the chord tolerance. +/// Whether a build's cancel flag has been set. +fn is_set(cancel: Option<&AtomicBool>) -> bool { + cancel.is_some_and(|c| c.load(Ordering::Relaxed)) +} + pub(super) fn draw(b: &mut Builder, ctx: &Ctx, e: &Entity, h: &Hatch, rgb: Ink, lw: f32, tol: f64) { let loops = fill_loops(h, tol); // Gradient and background colours given ByLayer follow the hatch's own layer. @@ -45,7 +51,7 @@ pub(super) fn draw(b: &mut Builder, ctx: &Ctx, e: &Entity, h: &Hatch, rgb: Ink, let inv = ctx.xf.inverse()?; Some(Bounds2::from_points(v.corners().iter().map(|p| inv.apply(*p)))) }); - for seg in pattern_lines(ctx.d, h, &loops, px, view.as_ref()) { + for seg in pattern_lines_until(ctx.d, h, &loops, px, view.as_ref(), b.opts.cancel.as_deref()) { if seg.len() == 1 { if let Some(p) = seg.first() { b.point(ctx, rgb, *p); @@ -328,6 +334,19 @@ pub fn reads_solid(d: &Drawing, h: &Hatch, px: f64) -> bool { /// pixels is one continuous run); 0 = full detail, as plots, exports and picks use. `view`: only /// the part of the hatch inside it is generated; `None` = all of it. pub fn pattern_lines(d: &Drawing, h: &Hatch, loops: &[Vec], px: f64, view: Option<&Bounds2>) -> Vec> { + pattern_lines_until(d, h, loops, px, view, None) +} + +/// [`pattern_lines`] that stops early (with what it has) once `cancel` is set: a dense pattern +/// over a large area is where most of a cancelled build's time would go. +pub(crate) fn pattern_lines_until( + d: &Drawing, + h: &Hatch, + loops: &[Vec], + px: f64, + view: Option<&Bounds2>, + cancel: Option<&AtomicBool>, +) -> Vec> { let lines = lines_of(d, h); let scale = scale_of(h); let px = if px.is_finite() && px > 0.0 { px } else { 0.0 }; @@ -341,8 +360,8 @@ pub fn pattern_lines(d: &Drawing, h: &Hatch, loops: &[Vec], px: f64, view: continue; } let dashes: Vec = fam.dashes.iter().map(|d| d * scale).collect(); - Family { origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, &mut out); - if out.len() >= MAX_RUNS { + Family { origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, cancel, &mut out); + if out.len() >= MAX_RUNS || is_set(cancel) { break; } } @@ -403,7 +422,7 @@ impl Family<'_> { self.origin + p.rotate(self.ang) } - fn sweep(&self, loops: &[Vec], px: f64, view: Option<&Bounds2>, out: &mut Vec>) { + fn sweep(&self, loops: &[Vec], px: f64, view: Option<&Bounds2>, cancel: Option<&AtomicBool>, out: &mut Vec>) { let local: Vec> = loops.iter().map(|l| l.iter().map(|p| self.to_local(*p)).collect()).collect(); let bb = Bounds2::from_points(local.iter().flatten().copied()); if bb.is_empty() { @@ -437,7 +456,7 @@ impl Family<'_> { let draws = self.dashes.iter().any(|d| *d >= 0.0); let solid_line = self.dashes.is_empty() || pat_len < 1e-12 || (px > 0.0 && pat_len < 2.0 * px && draws); for k in k0..=k1 { - if out.len() >= MAX_RUNS { + if out.len() >= MAX_RUNS || is_set(cancel) { return; } let y = k as f64 * self.spacing; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 7241362..8dadabe 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -222,6 +222,17 @@ pub struct Options { /// A layout viewport whose model-space contents are left out (its border is still drawn): /// the canvas draws the active viewport's contents itself, see [`viewport_content`]. pub skip_viewport: Option, + /// Set from another thread to stop the build early (the canvas cancels a background + /// rebuild the view no longer needs). A cancelled build returns a partial list, which the + /// caller must discard; see [`Options::cancelled`]. + pub cancel: Option>, +} + +impl Options { + /// Whether [`Options::cancel`] has been set. + pub fn cancelled(&self) -> bool { + self.cancel.as_ref().is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed)) + } } impl Default for Options { @@ -237,6 +248,7 @@ impl Default for Options { view: None, skip_viewport: None, transparency: false, + cancel: None, } } } @@ -531,8 +543,8 @@ const PARALLEL_CHUNK: usize = 256; /// Build model space for the screen on all cores: the entities are split into chunks built /// side by side, then joined in drawing order, so the list is the same as a one-thread build. -/// The canvas rebuilds the whole list at every 2x zoom step, on the UI thread; one core made -/// that a visible stall on real drawings. Plots, layouts and plot styles keep the one-thread +/// The canvas rebuilds the whole list at every 2x zoom step (in the background since zooming +/// stopped blocking the UI) and after every edit; one core made both slow on real drawings. Plots, layouts and plot styles keep the one-thread /// path (they run once, and their pens and per-primitive layers are built along the way). #[cfg(not(target_arch = "wasm32"))] fn build_parallel(b: &mut Builder, d: &Drawing, store: &cadcraft_doc::EntityStore) { @@ -991,8 +1003,9 @@ fn default_lineweight(d: &Drawing) -> f32 { } fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) { - // Hidden by ISOLATEOBJECTS / HIDEOBJECTS (and so in viewports and plots too). - if ctx.d.is_hidden_object(e.handle) { + // Hidden by ISOLATEOBJECTS / HIDEOBJECTS (and so in viewports and plots too). A cancelled + // build stops here (block contents come through here too). + if ctx.d.is_hidden_object(e.handle) || b.opts.cancelled() { return; } // The entity's named plot style and transparency (set by `entity_parts`), for everything it diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index a05ee9e..ee22abb 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -905,3 +905,22 @@ fn table_cell_text_height_scales_with_the_table() { assert_eq!(height(2.0, Some(150.0)), Some(300.0)); assert_eq!(height(2.0, None), Some(base * 2.0)); } + +#[test] +fn cancelled_build_stops_early() { + use std::sync::Arc; + use std::sync::atomic::AtomicBool; + let d = sample(); + let set = Some(Arc::new(AtomicBool::new(true))); + let full = build(&d, &Space::Model, &Options::default()); + assert!(!full.prims.is_empty()); + let opts = Options { cancel: set.clone(), ..Default::default() }; + assert!(opts.cancelled()); + assert!(build(&d, &Space::Model, &opts).prims.is_empty(), "no entity is drawn once cancelled"); + // A hatch sweep stops at its next pattern line. + let h = pattern_hatch("ANSI31", 1.0, (0.0, 0.0, 6.0, 4.0)); + let loops = loops_of(&h); + let drawing = cadcraft_doc::Drawing::new_imperial(); + assert!(!crate::hatch::pattern_lines_until(&drawing, &h, &loops, 0.0, None, None).is_empty()); + assert!(crate::hatch::pattern_lines_until(&drawing, &h, &loops, 0.0, None, set.as_deref()).is_empty()); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 28e17c3..d2c7315 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -35,18 +35,32 @@ impl Xf { } } +/// What a display list was built from: drawing uid, revision, tessellation band, hatch level of +/// detail band, lineweight display, document and active viewport view (see [`ensure_list`]). +type ListKey = (u64, u64, i32, i32, bool, bool, usize, u64); + +/// Whether two list keys are for the same drawing content (they may differ only in the view). +fn same_content(a: &ListKey, b: &ListKey) -> bool { + (a.0, a.1, a.4, a.5, a.6, a.7) == (b.0, b.1, b.4, b.5, b.6, b.7) +} + #[derive(Default)] pub struct CanvasState { pub list: Option, - key: (u64, u64, i32, i32, bool, bool, usize, u64), + key: ListKey, /// The area `list` was built for (hatch patterns exist only inside it); a view leaving it /// rebuilds the list. window: Option, + /// A rebuild for a new view running on a worker thread (see [`rebuild`]); `list` is drawn + /// meanwhile. + job: Option, /// Bumped whenever `list` is rebuilt (identifies it for [`PrimIndex`]). list_gen: u64, prim_index: PrimIndex, /// Set when the app runs on wgpu: entities are drawn by [`crate::gpu`]; otherwise on the CPU. pub gpu: Option, + /// The wgpu device, for background rebuilds to create their buffers (see [`rebuild`]). + pub gpu_device: Option, /// The mesh last handed to the GPU (see [`crate::gpu::CanvasCallback::key`]). mesh_key: Option, mesh_slot: crate::gpu::MeshSlot, @@ -122,6 +136,30 @@ impl CanvasState { pub fn viewport_content(&self) -> Option<(Bounds2, u64)> { self.vp.key.and(self.vp.paper_bounds()).map(|b| (b, self.vp.builds)) } + + /// Whether a rebuild for the current view is still running in the background (the canvas + /// shows the previous list until it lands). + pub fn building(&self) -> bool { + self.job.is_some() + } + + /// Put a finished background build in place, with its GPU vertex data when it has some. + fn install(&mut self, key: ListKey, window: Option, built: rebuild::Built) { + self.list_gen = self.list_gen.wrapping_add(1); + if let Some(m) = built.mesh { + if let Ok(mut slot) = self.mesh_slot.lock() { + *slot = Some(m.data); + } + self.mesh_key = Some(list_mesh_key(&key, self.list_gen, m.origin, m.bg, m.lwdisplay)); + self.mesh_origin = m.origin; + self.mesh_ms = m.ms; + self.infinite = m.infinite; + } + self.list = Some(built.list); + self.key = key; + self.window = window; + self.build_ms = built.build_ms; + } } /// Tab cycling through the candidate snaps under the cursor: where it started and which @@ -198,10 +236,14 @@ fn prim_color32(prim: &cadcraft_render::DPrim, bg: Rgb) -> Color32 { Color32::from_rgba_unmultiplied(c.0, c.1, c.2, prim.alpha()) } -/// Rebuild the display list when needed. `px`: drawing units per pixel in the current view; -/// `views`: every area on screen that shows the list (the current view, other model tiles) with -/// its drawing units per pixel. -fn ensure_list(app: &mut CadApp, px: f64, views: &[(Bounds2, f64)]) { +/// Rebuild the display list when needed. `xf`: the current view; `views`: every area on screen +/// that shows the list (the current view, other model tiles) with its drawing units per pixel. +/// +/// A rebuild only the view asks for (a new zoom band, a pan out of the built area) runs in the +/// background while the current list is drawn (see [`rebuild`]); a changed drawing, or no list +/// yet, is built at once. +fn ensure_list(app: &mut CadApp, xf: &Xf, views: &[(Bounds2, f64)], ctx: &egui::Context) { + let px = 1.0 / xf.scale; let Ok(st) = app.session.state() else { return }; // Rebuild when the drawing changes, the zoom moves by more than 2x (tessellation band) or a // view leaves the area the list was built for. @@ -224,22 +266,41 @@ fn ensure_list(app: &mut CadApp, px: f64, views: &[(Bounds2, f64)]) { } s.finish() }; - let settings = &app.session.settings; - let key = ( - st.uid, - st.revision, - band(px), - lod_band, - settings.lwdisplay, - settings.transparency_display, - std::sync::Arc::as_ptr(&st.doc) as usize, - vp_view, - ); + let (lwdisplay, transparency_display) = (app.session.settings.lwdisplay, app.session.settings.transparency_display); + let key: ListKey = + (st.uid, st.revision, band(px), lod_band, lwdisplay, transparency_display, std::sync::Arc::as_ptr(&st.doc) as usize, vp_view); + // A background build that finished: use it unless the drawing has changed since it started + // (or the list was dropped to force a rebuild). + if let Some(job) = &app.canvas.job { + match job.poll() { + rebuild::Poll::Pending => {} + rebuild::Poll::Done(built) => { + let (job_key, window) = (job.key, job.window); + app.canvas.job = None; + if app.canvas.list.is_some() && same_content(&job_key, &key) { + app.canvas.install(job_key, window, *built); + } + } + rebuild::Poll::Lost => { + log::warn!("canvas: a background rebuild failed; rebuilding on the UI thread"); + app.canvas.job = None; + app.canvas.list = None; + } + } + } let visible = views.iter().fold(Bounds2::EMPTY, |acc, v| acc.union(&v.0)); let inside = app.canvas.window.is_none_or(|w| visible.is_empty() || w.contains_box(&visible)); if app.canvas.list.is_some() && app.canvas.key == key && inside { return; } + // Only the view moved: keep drawing the current list and build the new one in the background. + let view_only = app.canvas.list.is_some() && same_content(&app.canvas.key, &key); + if view_only && app.canvas.job.as_ref().is_some_and(|j| j.serves(&key, &visible)) { + return; + } + // A build for another view, or for a drawing that has since changed, is no longer wanted: + // dropping it cancels it. + app.canvas.job = None; let t0 = crate::now_ms(); let opts = cadcraft_render::Options { tolerance: 2f64.powi(band(px)) * 0.5, @@ -248,13 +309,31 @@ fn ensure_list(app: &mut CadApp, px: f64, views: &[(Bounds2, f64)]) { min_dash: 2f64.powi(lod_band) * 2.0, text: true, fill: true, - lineweights: app.session.settings.lwdisplay, + lineweights: lwdisplay, view_height: st.paper_view().height, plot_style_table: None, view: build_window(views), transparency: app.session.settings.transparency_display, skip_viewport: split, + cancel: None, }; + if view_only { + let t = Tokens::get(); + let mesh = app.canvas.gpu.is_some().then(|| rebuild::MeshRequest { + view_center: xf.center, + view_height: f64::from(xf.rect.height()) / xf.scale.max(1e-300), + model_bg: Rgb(t.canvas.r(), t.canvas.g(), t.canvas.b()), + paper_bg: Rgb(t.paper.r(), t.paper.g(), t.paper.b()), + lwdisplay, + device: app.canvas.gpu_device.clone(), + }); + let window = opts.view; + let req = rebuild::Request { doc: st.doc.clone(), space: st.space.clone(), opts: opts.clone(), mesh }; + if let Some(job) = rebuild::spawn(req, key, window, ctx) { + app.canvas.job = Some(job); + return; + } + } let space = st.space.clone(); app.canvas.list = Some(cadcraft_render::build(&st.doc, &space, &opts)); app.canvas.key = key; @@ -397,6 +476,7 @@ fn ensure_vp_content(app: &mut CadApp, xf_paper: &Xf, ctx: &egui::Context) -> Op view: Some(area), skip_viewport: None, transparency, + cancel: None, }; c.list = cadcraft_render::viewport_content(&st.doc, &st.space, h, area, &opts); c.key = Some(key); @@ -840,17 +920,20 @@ fn draw_drag_gpu( )); } +/// Identifies the GPU vertex data of list `key` / `list_gen` converted at `origin` against `bg`. +fn list_mesh_key(key: &ListKey, list_gen: u64, origin: Vec2, bg: Rgb, lwdisplay: bool) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (key, list_gen, origin.x.to_bits(), origin.y.to_bits(), bg.0, bg.1, bg.2, lwdisplay).hash(&mut h); + h.finish() +} + /// Draw the display list through the GPU paint callback (uploading it when it changed), plus /// the infinite lines, which are clipped to the view on the CPU every frame. fn draw_list_gpu(c: &mut CanvasState, p: &egui::Painter, xf: &Xf, bg: Rgb, lwdisplay: bool) { let Some(list) = &c.list else { return }; let origin = crate::gpu::choose_origin(&list.bounds, xf.center, f64::from(xf.rect.height()) / xf.scale.max(1e-300)); - let key = { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - (c.key, c.list_gen, origin.x.to_bits(), origin.y.to_bits(), bg.0, bg.1, bg.2, lwdisplay).hash(&mut h); - h.finish() - }; + let key = list_mesh_key(&c.key, c.list_gen, origin, bg, lwdisplay); if c.mesh_key != Some(key) { let t0 = crate::now_ms(); let mesh = crate::gpu::build_mesh(list, origin, bg, lwdisplay); @@ -1517,7 +1600,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { let xf = tile_xf(*r, tile); (xf.world_bounds(), 1.0 / xf.scale) })); - ensure_list(app, 1.0 / xf_paper.scale, &views); + ensure_list(app, &xf_paper, &views, ui.ctx()); let sheet = app.canvas.list.as_ref().and_then(|l| l.sheet); let bg = match sheet { Some(sh) => { @@ -2523,6 +2606,9 @@ mod blockedit; /// The COMPARE overlay. mod compare; +/// Background rebuilds of the display list for a new view. +mod rebuild; + #[cfg(test)] mod hatch_pick_tests { use cadcraft_engine::Session; diff --git a/crates/ui-egui/src/canvas/rebuild.rs b/crates/ui-egui/src/canvas/rebuild.rs new file mode 100644 index 0000000..4a04d17 --- /dev/null +++ b/crates/ui-egui/src/canvas/rebuild.rs @@ -0,0 +1,224 @@ +//! Display-list rebuilds that only follow the view run on a worker thread, as AutoCAD redraws +//! instead of regenerating while you zoom and pan. +//! +//! Zooming past a 2x tessellation band or panning out of the area the list was built for used to +//! rebuild the list (and convert it to GPU vertex data) on the UI thread: on a hatched 12k-entity +//! drawing each step froze the window for 2-3 s. The current list stays valid at any view (only +//! hatch patterns are limited to its area, and curves and dashes are a band coarser or finer than +//! ideal), so the canvas keeps drawing it, scaled and moved by the view uniform, while the worker +//! builds the new one. A view that needs a different list cancels the build in flight (dropping +//! its [`Job`] sets the build's cancel flag, see `cadcraft_render::Options::cancel`) and starts +//! one for itself, so a long scroll through many zoom bands builds only for where it stops. +//! Edits still rebuild at once, so a change is never drawn late. The web build has no threads and +//! rebuilds synchronously. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{Receiver, TryRecvError}; + +use cadcraft_color::Rgb; +use cadcraft_doc::{Drawing, Space}; +use cadcraft_geom::{Bounds2, Vec2}; +use cadcraft_render::{DisplayList, Kind, Options}; + +/// What a build needs: the drawing (shared, never copied), the space and the options. +pub(super) struct Request { + pub doc: Arc, + pub space: Space, + pub opts: Options, + /// Also convert the list to GPU vertex data (GPU canvas only). + pub mesh: Option, +} + +/// How to convert the list for the GPU: the view the vertex origin is chosen for and the +/// background the colours resolve against (paper when the list is a layout's sheet). +pub(super) struct MeshRequest { + pub view_center: Vec2, + pub view_height: f64, + pub model_bg: Rgb, + pub paper_bg: Rgb, + pub lwdisplay: bool, + /// Create the GPU buffers on the worker too, so the frame that swaps the list in only swaps + /// buffers (`None`: the paint callback uploads them). + pub device: Option, +} + +/// A finished build. +pub(super) struct Built { + pub list: DisplayList, + pub build_ms: f64, + pub mesh: Option, +} + +pub(super) struct BuiltMesh { + pub data: crate::gpu::MeshData, + pub origin: Vec2, + pub bg: Rgb, + pub lwdisplay: bool, + /// Indices of the infinite lines (drawn on the CPU in GPU mode). + pub infinite: Vec, + pub ms: f64, +} + +/// A build in flight: the list key and area it is for, and where its result arrives. Dropping +/// it cancels the build. +pub(super) struct Job { + pub key: super::ListKey, + pub window: Option, + rx: Receiver, + cancel: Arc, +} + +impl Drop for Job { + fn drop(&mut self) { + self.cancel.store(true, Ordering::Relaxed); + } +} + +/// What [`Job::poll`] found. +pub(super) enum Poll { + Pending, + Done(Box), + /// The worker died without a result (it panicked). + Lost, +} + +impl Job { + pub fn poll(&self) -> Poll { + match self.rx.try_recv() { + Ok(b) => Poll::Done(Box::new(b)), + Err(TryRecvError::Empty) => Poll::Pending, + Err(TryRecvError::Disconnected) => Poll::Lost, + } + } + + /// Whether this build serves list `key` for the visible area `visible`. + pub fn serves(&self, key: &super::ListKey, visible: &Bounds2) -> bool { + self.key == *key && self.window.is_none_or(|w| visible.is_empty() || w.contains_box(visible)) + } +} + +/// Build `req` on the calling thread; `None` once it has been cancelled (the partial list is +/// thrown away, and no vertex data is made for it). +fn run(req: Request) -> Option { + let t0 = crate::now_ms(); + let list = cadcraft_render::build(&req.doc, &req.space, &req.opts); + if req.opts.cancelled() { + return None; + } + let build_ms = crate::now_ms() - t0; + let mesh = match req.mesh { + None => None, + Some(m) => { + let t1 = crate::now_ms(); + let origin = crate::gpu::choose_origin(&list.bounds, m.view_center, m.view_height); + let bg = if list.sheet.is_some() { m.paper_bg } else { m.model_bg }; + let mut data = crate::gpu::build_mesh(&list, origin, bg, m.lwdisplay); + if req.opts.cancelled() { + return None; + } + if let Some(device) = &m.device { + data.upload_now(device); + } + let infinite = list.prims.iter().enumerate().filter(|(_, p)| matches!(p.kind, Kind::Infinite { .. })).map(|(i, _)| i).collect(); + Some(BuiltMesh { data, origin, bg, lwdisplay: m.lwdisplay, infinite, ms: crate::now_ms() - t1 }) + } + }; + Some(Built { list, build_ms, mesh }) +} + +/// Start building `req` on a worker thread for `key` / `window`; the worker asks `ctx` for a +/// frame when it is done. `None` when there are no threads (the web build) or none could be +/// started: the caller builds synchronously. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn spawn(mut req: Request, key: super::ListKey, window: Option, ctx: &egui::Context) -> Option { + let (tx, rx) = std::sync::mpsc::channel(); + let cancel = Arc::new(AtomicBool::new(false)); + req.opts.cancel = Some(cancel.clone()); + let ctx = ctx.clone(); + let spawned = std::thread::Builder::new().name("cadcraft-regen".into()).spawn(move || { + // A panic drops `tx` unsent: the canvas sees the job as lost and builds on its own. A + // cancelled build sends nothing either, but its job is gone and nobody is listening. + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(req))) { + Ok(None) => {} + Ok(Some(built)) => { + let _ = tx.send(built); + ctx.request_repaint(); + } + Err(_) => { + drop(tx); + ctx.request_repaint(); + } + } + }); + match spawned { + Ok(_) => Some(Job { key, window, rx, cancel }), + Err(e) => { + log::warn!("canvas: could not start a regen thread ({e}); rebuilding on the UI thread"); + None + } + } +} + +#[cfg(target_arch = "wasm32")] +pub(super) fn spawn(_req: Request, _key: super::ListKey, _window: Option, _ctx: &egui::Context) -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn worker_build_matches_a_synchronous_one() { + use cadcraft_geom::Vec3; + let mut d = Drawing::new_imperial(); + let line = cadcraft_doc::Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(10.0, 5.0, 0.0) }; + let Ok(_) = d.add(&Space::Model, cadcraft_doc::Common::default(), cadcraft_doc::EntityKind::Line(line)) else { panic!("add") }; + let doc = Arc::new(d); + let opts = Options::default(); + let sync = cadcraft_render::build(&doc, &Space::Model, &opts); + let req = Request { + doc: doc.clone(), + space: Space::Model, + opts: opts.clone(), + mesh: Some(MeshRequest { + view_center: Vec2::ZERO, + view_height: 20.0, + model_bg: Rgb(0, 0, 0), + paper_bg: Rgb(255, 255, 255), + lwdisplay: false, + device: None, + }), + }; + let ctx = egui::Context::default(); + let Some(job) = spawn(req, Default::default(), None, &ctx) else { panic!("no worker") }; + let built = loop { + match job.poll() { + Poll::Done(b) => break b, + Poll::Pending => std::thread::sleep(std::time::Duration::from_millis(2)), + Poll::Lost => panic!("worker lost"), + } + }; + assert_eq!(built.list.prims.len(), sync.prims.len()); + assert_eq!(built.list.verts, sync.verts); + let Some(mesh) = built.mesh else { panic!("no mesh") }; + assert_eq!(mesh.bg, Rgb(0, 0, 0)); + assert!(mesh.data.line_vertices() >= 2); + } + + #[test] + fn cancelled_run_makes_nothing() { + let opts = Options { cancel: Some(Arc::new(AtomicBool::new(true))), ..Default::default() }; + let req = Request { doc: Arc::new(Drawing::new_imperial()), space: Space::Model, opts, mesh: None }; + assert!(run(req).is_none()); + } + + #[test] + fn dropping_a_job_cancels_its_build() { + let (_tx, rx) = std::sync::mpsc::channel(); + let cancel = Arc::new(AtomicBool::new(false)); + drop(Job { key: Default::default(), window: None, rx, cancel: cancel.clone() }); + assert!(cancel.load(Ordering::Relaxed)); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 10edc9c..03a3521 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -267,7 +267,7 @@ pub fn inspect(app: &CadApp, ctx: &egui::Context) -> Value { "snap": app.canvas.snap.map(|s| json!({"point": [s.point.x, s.point.y], "mode": s.name})), "session": app.session.summary(), "closePrompt": app.close_prompt(), - "perf": {"frameMs": app.frame_ms, "buildMs": app.canvas.build_ms, "drawMs": app.canvas.draw_ms, "meshMs": app.canvas.mesh_ms, "renderer": if app.canvas.gpu.is_some() { "gpu" } else { "cpu" }, "prims": app.canvas.list.as_ref().map(|l| l.prims.len())}, + "perf": {"frameMs": app.frame_ms, "buildMs": app.canvas.build_ms, "drawMs": app.canvas.draw_ms, "meshMs": app.canvas.mesh_ms, "building": app.canvas.building(), "renderer": if app.canvas.gpu.is_some() { "gpu" } else { "cpu" }, "prims": app.canvas.list.as_ref().map(|l| l.prims.len())}, }) } diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index 1eed268..eb19a42 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -108,9 +108,34 @@ pub struct MeshData { /// Draw-order breaks, one before each wipeout mask: the fill vertices, line vertices and /// quad instances drawn before it. Each group is drawn fills, lines, quads in turn. pub breaks: Vec<[usize; 3]>, + /// The vertex buffers already created from this data (see [`MeshData::upload_now`]); the + /// paint callback then only swaps them in. + uploaded: Option, +} + +/// GPU buffers made ahead of the frame that draws them. +struct Uploaded { + lines: Vec, + tris: Vec, + quads: Vec, } impl MeshData { + /// Create the vertex buffers now, on the calling thread, and let the CPU copy go. Making a + /// buffer zero-fills and copies it: about 0.35 s for a million-primitive drawing, which a + /// background rebuild does off the UI thread (see `canvas::rebuild`). + pub fn upload_now(&mut self, device: &wgpu::Device) { + let usage = wgpu::BufferUsages::VERTEX; + self.uploaded = Some(Uploaded { + lines: upload(device, "cad_lines", &self.lines, VERTEX_SIZE, usage), + tris: upload(device, "cad_fills", &self.tris, VERTEX_SIZE, usage), + quads: upload(device, "cad_quads", &self.quads, INSTANCE_SIZE, usage), + }); + self.lines = Vec::new(); + self.tris = Vec::new(); + self.quads = Vec::new(); + } + pub fn line_vertices(&self) -> usize { self.lines.len() / VERTEX_SIZE } @@ -163,6 +188,7 @@ pub fn build_mesh(list: &DisplayList, origin: Vec2, bg: Rgb, lwdisplay: bool) -> tris: Vec::with_capacity(list.tris.len().saturating_mul(VERTEX_SIZE)), quads: Vec::new(), breaks: Vec::new(), + uploaded: None, }; for prim in &list.prims { let col = premultiplied(prim.display_rgb(bg), prim.alpha()); @@ -678,11 +704,13 @@ impl egui_wgpu::CallbackTrait for CanvasCallback { let Some(res) = res.layers.get_mut(self.layer) else { return Vec::new() }; if res.key != Some(self.key) { let pending = self.slot.lock().ok().and_then(|mut s| s.take()); - if let Some(m) = pending { - let usage = wgpu::BufferUsages::VERTEX; - res.lines = upload(device, "cad_lines", &m.lines, VERTEX_SIZE, usage); - res.tris = upload(device, "cad_fills", &m.tris, VERTEX_SIZE, usage); - res.quads = upload(device, "cad_quads", &m.quads, INSTANCE_SIZE, usage); + if let Some(mut m) = pending { + if m.uploaded.is_none() { + m.upload_now(device); + } + if let Some(u) = m.uploaded { + (res.lines, res.tris, res.quads) = (u.lines, u.tris, u.quads); + } res.breaks = m.breaks; res.key = Some(self.key); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 40e2036..91c48e5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -297,6 +297,7 @@ impl CadApp { /// `NativeOptions::stencil_buffer`); clipped viewports are clipped with it. pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState, stencil: Option) { self.canvas.gpu = Some(gpu::install(rs, &self.gpu_faults, stencil)); + self.canvas.gpu_device = Some(rs.device.clone()); } pub fn with_control(mut self, rx: Receiver) -> Self { @@ -669,9 +670,12 @@ impl CadApp { fn issue_screenshots(&mut self, ctx: &egui::Context) { let now = now_ms(); + // Wait for a background rebuild for the current view, so the shot shows the drawing at + // that view, but not past the reply's deadline. + let building = self.canvas.building(); self.queued_shots.retain_mut(|(token, at, frames)| { *frames += 1; - if now >= *at && *frames >= 3 { + if now >= *at && *frames >= 3 && (!building || now >= *at + SHOT_REBUILD_WAIT_MS) { ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token))); false } else { @@ -717,6 +721,10 @@ impl CadApp { } } +/// How long a control-channel screenshot waits for a background rebuild before it is taken +/// anyway (the reply itself times out after 8 s). +const SHOT_REBUILD_WAIT_MS: f64 = 6000.0; + pub fn now_ms() -> f64 { #[cfg(not(target_arch = "wasm32"))] { diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index d6b03b4..7426773 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first hardware assessment) · **Target:** Autodesk AutoCAD 2027 (Mac and Windows) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (zoom and pan regenerate in the background) · **Target:** Autodesk AutoCAD 2027 (Mac and Windows) The hardware AutoCAD uses and how CADCraft compares, per platform. Work items: [gaps.md](gaps.md#hardware). @@ -37,8 +37,16 @@ From the earlier roadmap (measured by the agents that built it, 2026-10-06, `cad canvas draws a frame in about 4 ms. Nothing has been compared with AutoCAD on the same machine, and 1M-entity drawings are untested. A 10 MB DWG took about two minutes to fail (#57). +Zoom and pan no longer freeze the window (2026-10-13). On a hatched 12k-entity architect's DWG +(about 340k display primitives) each zoom step used to block the UI for 2–4.5 s while the +display list, its vertex data and its GPU buffers were rebuilt. These rebuilds now run on a +worker thread while the previous list is drawn, so the longest stall is 26–64 ms (M-series Mac). +Edits still rebuild on the UI thread (about 1.8 s on that drawing): per-entity caching is the +next step. + ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Zoom and pan regenerate in the background: 2–4.5 s stalls down to 26–64 ms on a hatched DWG | | 2026-10-10 | major | First hardware parity doc: GPU backends per platform, input devices, displays, plotters |