Canvas: the last frame turns with the view until the frame at the new angle is ready

When the last frame took more than 8 ms to render, the desktop app's
canvas sends the next frame to the render worker and draws the last
one until the new one arrives. After a pan or a zoom it draws the last
frame moved and scaled. After the view angle changed, it drew no art
until a frame at the new angle arrived. The angle changes during a
Rotate View drag and when the status bar's angle menu, Reset Rotate
View or a saved view sets it. In a headless run of the canvas on a
document of 50,000 paths, none of the 60 frames of a turn from 0° to
90° drew any art.

The canvas now draws the last frame as a mesh turned into the new
view. Each corner goes where the new view puts the document point that
the corner showed. The frame at the new angle replaces it when it
arrives, as before. Pixel Preview's frames already turned with the
view and are drawn as before.

Docs: docs/target-app-parity.md.
This commit is contained in:
dotCooCoo authored and bflatastic committed 2026-10-14 06:01:10 +07:00
1 parent 7975b07b22
commit b3ac169f00
2 files changed
+83 -1

No files matched your search

+82 -1
View File
@@ -361,7 +361,20 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
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);
}
None => {}
// The view turned after the last frame was rendered, and the worker renders the frame at
// the new angle. Until it arrives, the last frame is drawn turned into this view: each
// corner goes where this view puts the document point that the corner showed.
None => {
let old = Xf { rect, zoom: k.zoom, center: Point::new(k.cx, k.cy), rot: k.rot.to_radians() };
let q = [rect.left_top(), rect.right_top(), rect.right_bottom(), rect.left_bottom()].map(|c| xf.to_screen(old.to_doc(c)));
let mut mesh = egui::Mesh::with_texture(tex.id());
for (pos, uv) in q.into_iter().zip([pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0), pos2(0.0, 1.0)]) {
mesh.vertices.push(egui::epaint::Vertex { pos, uv, color: Color32::WHITE });
}
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(0, 2, 3);
painter.add(Shape::mesh(mesh));
}
}
}
if let Some(look) = grid_look.filter(|_| !grids_in_back) {
@@ -3150,6 +3163,74 @@ mod tests {
assert!(!app.canvas.moved);
}
/// When the view turns while the worker renders the frame at the new angle (Reset Rotate View on
/// a heavy document), the canvas draws the last frame turned until that frame arrives: each
/// corner goes where the new view puts the document point that the corner showed. The new frame
/// is then drawn unturned. In Pixel Preview the last frame's pixels stay over the document rect
/// they cover.
#[test]
fn a_turned_view_draws_the_last_frame_turned_until_its_own_arrives() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap();
app.canvas.worker_started = true; // render on this thread
let ctx = egui::Context::default();
shapes(&mut app, &ctx); // the first frame fits the view to the artboard
app.view_mut().unwrap().rotation = 30.0;
shapes(&mut app, &ctx);
let rect = app.canvas_rect.unwrap();
let old = Xf::new(rect, app.view().unwrap());
// `art` runs one frame and finds the mesh that draws the art's texture.
let art = |app: &mut VectorcraftApp| {
let s = shapes(app, &ctx);
let tex = app.canvas.texture.as_ref().unwrap().id();
s.into_iter().find_map(|s| match s {
Shape::Mesh(m) if m.texture_id == tex => Some(m),
_ => None,
})
};
// With a worker and a last frame that took 50 ms, the next frame renders on the worker.
app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone());
app.canvas.last_ms = 50.0;
app.run("view.rotateReset", json!({})).unwrap();
let mesh = art(&mut app).expect("the last frame, turned");
assert!(app.canvas.worker.as_ref().and_then(|w| w.pending.as_ref()).is_some_and(|k| k.rot == 0.0), "sent to the worker");
let new = Xf::new(rect, app.view().unwrap());
let corners = [rect.left_top(), rect.right_top(), rect.right_bottom(), rect.left_bottom()];
assert_eq!(mesh.vertices.len(), 4);
for ((v, corner), uv) in mesh.vertices.iter().zip(corners).zip([pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0), pos2(0.0, 1.0)]) {
let at = new.to_screen(old.to_doc(corner));
assert!((v.pos - at).length() < 0.01 && v.uv == uv, "{v:?}: texture corner {uv:?} at {at:?}");
}
// The point where the last frame drew the rectangle's top-left corner lands where the new view puts that corner.
let p = Point::new(100.0, 50.0);
let uv = (old.to_screen(p) - rect.min) / rect.size();
let [a, b, _, d] = [0, 1, 2, 3].map(|i| mesh.vertices[i].pos);
assert!((a + (b - a) * uv.x + (d - a) * uv.y - new.to_screen(p)).length() < 0.01, "the rectangle's corner");
// The worker's frame at 0° then covers the canvas unturned.
let until = std::time::Instant::now() + std::time::Duration::from_secs(60);
while app.canvas.key.as_ref().is_some_and(|k| k.rot != 0.0) && std::time::Instant::now() < until {
std::thread::sleep(std::time::Duration::from_millis(5));
shapes(&mut app, &ctx);
}
assert!(app.canvas.key.as_ref().is_some_and(|k| k.rot == 0.0), "the worker's frame arrives");
let b = art(&mut app).expect("the worker's frame").calc_bounds();
assert!((b.min - rect.min).length() < 0.01 && (b.max - rect.max).length() < 0.01, "{b:?} over {rect:?}");
// In Pixel Preview the last frame's pixels are drawn over the document rect they cover.
app.canvas.worker = None;
app.ui.view.pixel_preview = true;
app.view_mut().unwrap().zoom = 8.0;
shapes(&mut app, &ctx);
let [x0, y0, ..] = app.canvas.key.as_ref().and_then(|k| k.pixel).expect("the pixels rendered");
app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone());
app.canvas.last_ms = 50.0;
app.view_mut().unwrap().rotation = 30.0;
let mesh = art(&mut app).expect("the last frame's pixels");
let at = Xf::new(rect, app.view().unwrap()).to_screen(Point::new(x0 as f64, y0 as f64));
assert!((mesh.vertices[0].pos - at).length() < 0.01, "{:?} at {at:?}", mesh.vertices[0].pos);
app.canvas.worker = None;
}
/// File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (#394): off, Pixel
/// Preview draws images with each pixel taking its nearest image pixel; on, smoothly, as the
/// canvas always does out of Pixel Preview.
+1
View File
@@ -468,6 +468,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard, fits it and synchronizes the Artboard tool selection when that tool is active; ordinary object selection is preserved. Actual Next-button regressions cover keyboard/Properties deletion and Undo in `chrome.rs`. Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them). In Outline view a template layer keeps drawing in Preview, dimmed, so a picture placed to be traced stays visible under the outlines (#1505, fixes #1486).
Align to Artboard uses the active artboard, the one the Artboards panel highlights (`object.align {artboard}`). A press with a selection tool inside an artboard makes that artboard active; with the Selection tool, a Shift-click on an empty spot inside an artboard makes it active and keeps the selection. One selected object is aligned to the active artboard too, whatever Align To says (a lone object is never the key object). When an action plays, its Align to Artboard, All on Active Artboard, Paste in Place, Paste in Front and Paste in Back steps that name no artboard use the artboard that is active then.
Choosing the Artboard tool (Shift+O, its toolbar button, Edit Artboards in Properties or Document Setup, or the Artboards panel menu's Artboard Options…) starts it on the active artboard: the canvas outlines that artboard, and Delete, Cut, Copy, the Transform panel and Artboard Options act on it.
When the view angle changes (a Rotate View drag, the status bar's angle menu, Reset Rotate View or a saved view) and the desktop app renders the frame at the new angle on the render worker, the canvas draws the last frame turned to the new angle until that frame is ready. It used to draw no art until then.
- **Geometry and effects:**
- Pathfinder (10 exact curve booleans; single-result shape operations retain source names and SVG IDs, #1293), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. With one group selected alone, the Pathfinder panel's buttons work on its members (among other selected objects a group still acts as one shape), and a greyed-out button says what it needs (#1450; behaviour reference: [combine objects using Pathfinder effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/combine-objects-using-pathfinder-effects.html); one plain path selected alone stays disabled, which the documentation doesn't settle). Split Into Grid takes the number, height or width, gutter and total of the rows and of the columns, Add Guides and Preview, and several selected paths become one grid painted as the topmost one (#1462, `object.path.splitIntoGrid`; behaviour reference: [divide or split objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/divide-or-split-objects.html); a typed height or width keeps the gutter and changes the total, our choice). Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart. In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635).
- Divide uses open lines, bent paths and curves as cutting edges (#1307), preserving the frontmost filled face's appearance and holes. Engine tests cover implicit closure of filled open contours and unrelated objects; native automation confirms independent piece movement, one Undo/Redo, canvas/window captures and an exact saved-document read by a separate CLI process. The shared planar map retains its 4,096-segment cap and filled-area fallback; other Pathfinder operations are unchanged.