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:
parent
db38f51046
commit
d0007a604d
|
|
@ -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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue