mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Merge pull request #1819 from dotCooCoo/feat/rotate-view-keeps-old-texture
Canvas: the last frame turns with the view until the frame at the new angle is ready
This commit is contained in:
2 files changed
+85
-11
No files matched your search
@@ -345,14 +345,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
match k.pixel {
|
||||
// Document pixels: placed over the document rect they cover, turning with the view.
|
||||
Some([x0, y0, x1, y1]) => {
|
||||
let q = xf.quad(Rect::new(x0 as f64, y0 as f64, x1 as f64, y1 as f64));
|
||||
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));
|
||||
textured_quad(&painter, tex.id(), &xf.quad(Rect::new(x0 as f64, y0 as f64, x1 as f64, y1 as f64)), (1.0, 1.0));
|
||||
}
|
||||
None if (k.rot - v.rotation).abs() < 1e-9 => {
|
||||
// Reproject the last frame if it was rendered for a different view.
|
||||
@@ -361,7 +354,14 @@ 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)));
|
||||
textured_quad(&painter, tex.id(), &q, (1.0, 1.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(look) = grid_look.filter(|_| !grids_in_back) {
|
||||
@@ -986,10 +986,15 @@ pub(crate) const TRANSPARENCY_GRID: &str = "transparency grid";
|
||||
/// The transparency grid over the artboard `ab` (screen corners `quad`): one textured quad whose
|
||||
/// cells stay `cell` screen points at every zoom and turn with a rotated view.
|
||||
fn checker(p: &egui::Painter, quad: &[Pos2], ab: Rect, zoom: f64, tex: egui::TextureId, cell: f32) {
|
||||
let mut mesh = egui::Mesh::with_texture(tex);
|
||||
// Two cells per texture repeat.
|
||||
let per = (2.0 * cell as f64 / zoom).max(1e-9);
|
||||
let (u, v) = ((ab.width() / per) as f32, (ab.height() / per) as f32);
|
||||
textured_quad(p, tex, quad, ((ab.width() / per) as f32, (ab.height() / per) as f32));
|
||||
}
|
||||
|
||||
/// One quad drawn with `tex`: its corners `quad` (from the top left, clockwise) show the texture
|
||||
/// from (0, 0) to (`u`, `v`).
|
||||
fn textured_quad(p: &egui::Painter, tex: egui::TextureId, quad: &[Pos2], (u, v): (f32, f32)) {
|
||||
let mut mesh = egui::Mesh::with_texture(tex);
|
||||
for (pos, uv) in quad.iter().zip([pos2(0.0, 0.0), pos2(u, 0.0), pos2(u, v), pos2(0.0, v)]) {
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos: *pos, uv, color: Color32::WHITE });
|
||||
}
|
||||
@@ -3150,6 +3155,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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user