Zoom and pan regenerate in the background instead of freezing the window (#903)

* Zoom and pan regenerate in the background instead of freezing the window

A rebuild that only the view asks for (a new 2x zoom band, a pan out of the
built area) now runs on a worker thread while the canvas keeps drawing the
previous display list, scaled by the view uniform. The worker builds the list,
converts it to vertex data and creates the GPU buffers, so the frame that swaps
it in only swaps buffers. One build runs at a time; a result from an older
drawing revision is dropped. Edits, the first build and the web build (no
threads) still rebuild synchronously.

ZOOM no longer calls touch(): it changes the view, not the drawing, and the
bumped revision made every typed zoom a full blocking rebuild (wheel zoom
never did). REGEN, REGENALL and layout switches still touch.

ui.screenshot waits (up to 6 s) for a background rebuild so agents capture
the new view; ui.inspect perf reports "building".

On a hatched 12k-entity DWG (~340k primitives) the longest UI stall per zoom
went from 2-4.5 s to 26-64 ms.

Also: cargo fmt of a comment in engine/src/prompt.rs that was failing the fmt
gate on main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Cancel a background rebuild when the view moves on

A view that needs a different display list now cancels the build in flight
and starts one for itself, instead of waiting for the outdated build to land
and only then starting the next. A long scroll through many zoom bands builds
only for where it stops: the list lands about one build after the last step
(2.0-2.3 s on a hatched 12k-entity DWG) instead of up to two, and an outdated
build no longer keeps a core busy.

cadcraft_render::Options gains `cancel` (an Arc<AtomicBool>); a set flag stops
the build at the next entity (block contents included) and the hatch sweep at
its next pattern line, returning a partial list the caller discards. The
canvas's Job sets its flag when dropped, so replacing or dropping a job
cancels it; a cancelled worker sends nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* cargo fmt: crates/doc/src/dynblock.rs (fails the fmt gate on main)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-13 06:58:16 -04:00
1 parent 1e075f76c5
commit 8cd0d46d58
11 files changed
+450 -44

No files matched your search

+3 -3
View File
@@ -178,7 +178,9 @@ pub(crate) fn zoom(s: &mut Session, p: &Value) -> Result<Value> {
}
_ => 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)) => {
+1
View File
@@ -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());
+24 -5
View File
@@ -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<Vec2>], px: f64, view: Option<&Bounds2>) -> Vec<Vec<Vec2>> {
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<Vec2>],
px: f64,
view: Option<&Bounds2>,
cancel: Option<&AtomicBool>,
) -> Vec<Vec<Vec2>> {
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<Vec2>], px: f64, view:
continue;
}
let dashes: Vec<f64> = 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<Vec2>], px: f64, view: Option<&Bounds2>, out: &mut Vec<Vec<Vec2>>) {
fn sweep(&self, loops: &[Vec<Vec2>], px: f64, view: Option<&Bounds2>, cancel: Option<&AtomicBool>, out: &mut Vec<Vec<Vec2>>) {
let local: Vec<Vec<Vec2>> = 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;
+17 -4
View File
@@ -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<Handle>,
/// 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<std::sync::Arc<std::sync::atomic::AtomicBool>>,
}
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
+19
View File
@@ -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());
}
+110 -24
View File
@@ -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<DisplayList>,
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<Bounds2>,
/// A rebuild for a new view running on a worker thread (see [`rebuild`]); `list` is drawn
/// meanwhile.
job: Option<rebuild::Job>,
/// 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<crate::gpu::GpuTarget>,
/// The wgpu device, for background rebuilds to create their buffers (see [`rebuild`]).
pub gpu_device: Option<egui_wgpu::wgpu::Device>,
/// The mesh last handed to the GPU (see [`crate::gpu::CanvasCallback::key`]).
mesh_key: Option<u64>,
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<Bounds2>, 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;
+224
View File
@@ -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<Drawing>,
pub space: Space,
pub opts: Options,
/// Also convert the list to GPU vertex data (GPU canvas only).
pub mesh: Option<MeshRequest>,
}
/// 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<egui_wgpu::wgpu::Device>,
}
/// A finished build.
pub(super) struct Built {
pub list: DisplayList,
pub build_ms: f64,
pub mesh: Option<BuiltMesh>,
}
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<usize>,
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<Bounds2>,
rx: Receiver<Built>,
cancel: Arc<AtomicBool>,
}
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<Built>),
/// 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<Built> {
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<Bounds2>, ctx: &egui::Context) -> Option<Job> {
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<Bounds2>, _ctx: &egui::Context) -> Option<Job> {
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));
}
}
+1 -1
View File
@@ -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())},
})
}
+33 -5
View File
@@ -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<Uploaded>,
}
/// GPU buffers made ahead of the frame that draws them.
struct Uploaded {
lines: Vec<Batch>,
tris: Vec<Batch>,
quads: Vec<Batch>,
}
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);
}
+9 -1
View File
@@ -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<egui_wgpu::wgpu::TextureFormat>) {
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<ControlRequest>) -> 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"))]
{
+9 -1
View File
@@ -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 |