M5.1: background render worker with reprojection; Layers rename + drag reorder

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-09-30 06:33:47 -04:00
parent db38f51046
commit d0007a604d
4 changed files with 184 additions and 15 deletions

View File

@ -133,21 +133,42 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) {
let ppp = ui.ctx().pixels_per_point(); let ppp = ui.ctx().pixels_per_point();
let (w, h) = ((rect.width() * ppp).round().max(1.0) as u32, (rect.height() * ppp).round().max(1.0) as u32); let (w, h) = ((rect.width() * ppp).round().max(1.0) as u32, (rect.height() * ppp).round().max(1.0) as u32);
let key = CacheKey { doc: app.session.active_index().unwrap_or(0), revision: st.revision, zoom: v.zoom, cx: v.center.x, cy: v.center.y, w, h, outline: app.ui.view.outline, ppp, hidden: vec![] }; let key = CacheKey { doc: app.session.active_index().unwrap_or(0), revision: st.revision, zoom: v.zoom, cx: v.center.x, cy: v.center.y, w, h, outline: app.ui.view.outline, ppp, hidden: vec![] };
if !app.canvas.worker_started {
app.canvas.worker_started = true;
if std::env::var_os("DRAWCRAFT_SYNC_RENDER").is_none() {
app.canvas.worker = crate::render_worker::Worker::spawn(ui.ctx().clone());
}
}
// Upload finished background renders.
if let Some(done) = app.canvas.worker.as_mut().and_then(|w| w.poll()) {
upload(app, ui.ctx(), &done.img);
app.canvas.key = Some(done.key);
app.perf.render_ms = done.ms;
app.canvas.last_ms = done.ms;
}
if app.canvas.key.as_ref() != Some(&key) || app.canvas.texture.is_none() { if app.canvas.key.as_ref() != Some(&key) || app.canvas.texture.is_none() {
let t0 = now_ms();
let view = Affine::translate((w as f64 / 2.0, h as f64 / 2.0)) * Affine::scale(v.zoom * ppp as f64) * Affine::translate(-v.center.to_vec2()); let view = Affine::translate((w as f64 / 2.0, h as f64 / 2.0)) * Affine::scale(v.zoom * ppp as f64) * Affine::translate(-v.center.to_vec2());
let opts = drawcraft_render::RenderOptions { outline: app.ui.view.outline, background: None, artboards: false, ..Default::default() }; let opts = drawcraft_render::RenderOptions { outline: app.ui.view.outline, background: None, artboards: false, ..Default::default() };
// Light documents render synchronously (no lag vs overlays); heavy ones go to the worker.
let heavy = app.canvas.last_ms > 8.0 && app.canvas.texture.is_some();
match (&mut app.canvas.worker, heavy) {
(Some(worker), true) => worker.submit(crate::render_worker::Job { key: key.clone(), doc: doc.clone(), w, h, view, opts }),
_ => {
let t0 = now_ms();
let img = app.canvas.renderer.render(&doc, w, h, view, &opts); let img = app.canvas.renderer.render(&doc, w, h, view, &opts);
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); upload(app, ui.ctx(), &img);
match &mut app.canvas.texture { app.canvas.key = Some(key.clone());
Some(tex) => tex.set(color, egui::TextureOptions::LINEAR),
None => app.canvas.texture = Some(ui.ctx().load_texture("canvas", color, egui::TextureOptions::LINEAR)),
}
app.canvas.key = Some(key);
app.perf.render_ms = now_ms() - t0; app.perf.render_ms = now_ms() - t0;
app.canvas.last_ms = app.perf.render_ms;
} }
if let Some(tex) = &app.canvas.texture { }
painter.image(tex.id(), rect, egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); }
if let (Some(tex), Some(k)) = (&app.canvas.texture, &app.canvas.key) {
// Reproject the last frame if it was rendered for a different view.
let old = Xf { rect, zoom: k.zoom, center: Point::new(k.cx, k.cy) };
let a = xf.to_screen(old.to_doc(rect.min));
let b = xf.to_screen(old.to_doc(rect.max));
painter.image(tex.id(), egui::Rect::from_min_max(a, b), egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
} }
// Artboard edges and names. // Artboard edges and names.
let active_ab = 0; let active_ab = 0;
@ -746,3 +767,11 @@ fn home(app: &mut DrawcraftApp, ui: &mut Ui, rect: egui::Rect) {
fn kurbo_flatten(p: &BezPath, tol: f64, f: &mut impl FnMut(PathEl)) { fn kurbo_flatten(p: &BezPath, tol: f64, f: &mut impl FnMut(PathEl)) {
drawcraft_geom::kurbo::flatten(p.elements().iter().copied(), tol, f); drawcraft_geom::kurbo::flatten(p.elements().iter().copied(), tol, f);
} }
fn upload(app: &mut DrawcraftApp, ctx: &egui::Context, img: &drawcraft_render::Rendered) {
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
match &mut app.canvas.texture {
Some(tex) => tex.set(color, egui::TextureOptions::LINEAR),
None => app.canvas.texture = Some(ctx.load_texture("canvas", color, egui::TextureOptions::LINEAR)),
}
}

View File

@ -15,6 +15,7 @@ pub mod icons;
pub mod io; pub mod io;
pub mod menus; pub mod menus;
pub mod palette; pub mod palette;
pub mod render_worker;
pub mod panels; pub mod panels;
pub mod shortcuts; pub mod shortcuts;
pub mod state; pub mod state;
@ -60,6 +61,8 @@ pub struct CanvasCache {
pub texture: Option<egui::TextureHandle>, pub texture: Option<egui::TextureHandle>,
pub key: Option<CacheKey>, pub key: Option<CacheKey>,
pub last_ms: f64, pub last_ms: f64,
pub worker: Option<render_worker::Worker>,
pub worker_started: bool,
} }
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
@ -116,7 +119,7 @@ impl DrawcraftApp {
ui: UiState::default(), ui: UiState::default(),
views, views,
services, services,
canvas: CanvasCache { renderer: drawcraft_render::Renderer::new(), texture: None, key: None, last_ms: 0.0 }, canvas: CanvasCache { renderer: drawcraft_render::Renderer::new(), texture: None, key: None, last_ms: 0.0, worker: None, worker_started: false },
perf: Perf::default(), perf: Perf::default(),
integrated_titlebar: false, integrated_titlebar: false,
control_rx: None, control_rx: None,

View File

@ -32,6 +32,9 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) {
} }
}); });
ui.data_mut(|d| d.insert_temp(expanded_id(), expanded)); ui.data_mut(|d| d.insert_temp(expanded_id(), expanded));
if ui.input(|i| i.pointer.any_released()) {
ui.data_mut(|d| d.remove::<u64>(egui::Id::new("layers-drag")));
}
// Bottom bar. // Bottom bar.
let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover()); let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover());
ui.painter().line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.divider)); ui.painter().line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.divider));
@ -73,7 +76,7 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) {
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn row(ui: &mut Ui, doc: &drawcraft_doc::Document, n: &Node, depth: usize, sel: &HashSet<NodeId>, current: Option<NodeId>, expanded: &mut HashSet<u64>, actions: &mut Vec<(String, serde_json::Value)>, t: &Tokens) { fn row(ui: &mut Ui, doc: &drawcraft_doc::Document, n: &Node, depth: usize, sel: &HashSet<NodeId>, current: Option<NodeId>, expanded: &mut HashSet<u64>, actions: &mut Vec<(String, serde_json::Value)>, t: &Tokens) {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW), Sense::click()); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW), Sense::click_and_drag());
let is_sel = sel.contains(&n.id); let is_sel = sel.contains(&n.id);
let child_sel = !is_sel && n.children().is_some_and(|_| { let child_sel = !is_sel && n.children().is_some_and(|_| {
let mut any = false; let mut any = false;
@ -148,7 +151,30 @@ fn row(ui: &mut Ui, doc: &drawcraft_doc::Document, n: &Node, depth: usize, sel:
// Name. // Name.
let name = n.display_name(); let name = n.display_name();
let font = if n.is_layer() { egui::FontId::proportional(12.5) } else { egui::FontId::proportional(12.0) }; let font = if n.is_layer() { egui::FontId::proportional(12.5) } else { egui::FontId::proportional(12.0) };
ui.painter().with_clip_rect(egui::Rect::from_min_max(egui::pos2(x, r.top()), egui::pos2(r.right() - 44.0, r.bottom()))).text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name, font, t.text); let rename_id = egui::Id::new("layers-rename");
let renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id));
let name_rect = egui::Rect::from_min_max(egui::pos2(x - 2.0, r.top() + 3.0), egui::pos2(r.right() - 44.0, r.bottom() - 3.0));
match renaming {
Some((rid, mut buf)) if rid == n.id.0 => {
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect));
let te = child.add(egui::TextEdit::singleline(&mut buf).desired_width(name_rect.width()).font(font.clone()));
te.request_focus();
if te.lost_focus() {
ui.data_mut(|d| d.remove::<(u64, String)>(rename_id));
if child.input(|i| !i.key_pressed(egui::Key::Escape)) && buf != name {
let cmd = if n.is_layer() { "layer.setProps" } else { "object.setProps" };
let key = if n.is_layer() { "id" } else { "ids" };
let idv = if n.is_layer() { json!(n.id.0) } else { json!([n.id.0]) };
actions.push((cmd.into(), json!({key: idv, "name": buf})));
}
} else {
ui.data_mut(|d| d.insert_temp(rename_id, (n.id.0, buf)));
}
}
_ => {
ui.painter().with_clip_rect(name_rect).text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name.clone(), font, t.text);
}
}
// Target circle and selection square. // Target circle and selection square.
let tc = egui::pos2(r.right() - 30.0, r.center().y); let tc = egui::pos2(r.right() - 30.0, r.center().y);
let styled = !n.appearance.is_basic() || n.opacity < 1.0; let styled = !n.appearance.is_basic() || n.opacity < 1.0;
@ -181,9 +207,36 @@ fn row(ui: &mut Ui, doc: &drawcraft_doc::Document, n: &Node, depth: usize, sel:
actions.push(("select.set".into(), json!({"ids": [n.id.0]}))); actions.push(("select.set".into(), json!({"ids": [n.id.0]})));
} }
} }
if resp.double_clicked() && n.is_layer() { if resp.double_clicked() {
// Rename via Layer Options (later: inline edit). ui.data_mut(|d| d.insert_temp(egui::Id::new("layers-rename"), (n.id.0, n.display_name())));
} }
// Drag to reorder: drop onto a row moves the dragged node above it (into its parent).
let drag_id = egui::Id::new("layers-drag");
if resp.drag_started() {
ui.data_mut(|d| d.insert_temp(drag_id, n.id.0));
}
let dragging: Option<u64> = ui.data(|d| d.get_temp(drag_id));
if let Some(src) = dragging
&& src != n.id.0
&& ui.rect_contains_pointer(r)
{
let above = ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| p.y < r.center().y);
let y = if above { r.top() } else { r.bottom() };
ui.painter().line_segment([egui::pos2(r.left() + 46.0, y), egui::pos2(r.right(), y)], Stroke::new(2.0, t.accent));
if ui.input(|i| i.pointer.any_released()) {
let (parent, index) = match doc.position(n.id) {
Some((par, idx, _)) => (par, if above { idx + 1 } else { idx }),
None => (None, 0),
};
// A layer dropped on a non-layer goes into that row's container; top level only for layers.
let src_is_layer = doc.node(drawcraft_doc::NodeId(src)).is_some_and(|x| x.is_layer());
if parent.is_some() || src_is_layer {
actions.push(("node.move".into(), json!({"id": src, "parent": parent.map(|p| p.0), "index": index})));
}
ui.data_mut(|d| d.remove::<u64>(drag_id));
}
}
if has_children && expanded.contains(&n.id.0) { if has_children && expanded.contains(&n.id.0) {
for c in n.children().unwrap().iter().rev() { for c in n.children().unwrap().iter().rev() {
row(ui, doc, c, depth + 1, sel, current, expanded, actions, t); row(ui, doc, c, depth + 1, sel, current, expanded, actions, t);

View File

@ -0,0 +1,84 @@
//! Background rendering (native): heavy documents render off the UI thread while the canvas shows
//! the previous frame reprojected to the current view. Light documents render synchronously so
//! edits never lag behind overlays.
use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender, channel};
use drawcraft_doc::Document;
use drawcraft_geom::Affine;
use drawcraft_render::{RenderOptions, Rendered, Renderer};
use crate::CacheKey;
pub struct Job {
pub key: CacheKey,
pub doc: Arc<Document>,
pub w: u32,
pub h: u32,
pub view: Affine,
pub opts: RenderOptions,
}
pub struct Done {
pub key: CacheKey,
pub img: Rendered,
pub ms: f64,
}
pub struct Worker {
tx: Sender<Job>,
rx: Receiver<Done>,
pub pending: Option<CacheKey>,
}
impl Worker {
/// Spawn the worker thread (None on wasm, where rendering stays synchronous).
pub fn spawn(ctx: egui::Context) -> Option<Self> {
if cfg!(target_arch = "wasm32") {
return None;
}
let (tx, jobs) = channel::<Job>();
let (done_tx, rx) = channel::<Done>();
std::thread::Builder::new()
.name("drawcraft-render".into())
.spawn(move || {
let mut r = Renderer::new();
while let Ok(mut job) = jobs.recv() {
// Skip stale jobs: only the newest matters.
while let Ok(newer) = jobs.try_recv() {
job = newer;
}
let t0 = crate::now_ms();
let img = r.render(&job.doc, job.w, job.h, job.view, &job.opts);
if done_tx.send(Done { key: job.key, img, ms: crate::now_ms() - t0 }).is_err() {
break;
}
ctx.request_repaint();
}
})
.ok()?;
Some(Self { tx, rx, pending: None })
}
pub fn submit(&mut self, job: Job) {
if self.pending.as_ref() == Some(&job.key) {
return;
}
self.pending = Some(job.key.clone());
let _ = self.tx.send(job);
}
pub fn poll(&mut self) -> Option<Done> {
let mut last = None;
while let Ok(d) = self.rx.try_recv() {
last = Some(d);
}
if let Some(d) = &last
&& self.pending.as_ref() == Some(&d.key)
{
self.pending = None;
}
last
}
}