diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 58a037df2..9639d0225 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -936,9 +936,18 @@ fn beyond_horizon() -> EngineError { EngineError::Other("the pointer is beyond the plane's horizon".into()) } +/// Whether the grid stored in `d` lists attachments of its own, as grids stored before attachments +/// were kept on the objects do ([`PerspectiveGrid::adopt_stored_attachments`]). +fn stores_attachments(d: &Document) -> bool { + !PerspectiveGrid::current(d).attached.is_empty() +} + /// Edit objects in perspective (one undo step `label`): with `copy` their duplicates (which stay /// attached), each mapped by `step` for its own plane and depth; objects not in perspective attach -/// to `fallback` first. → the edited objects and where the first one was (plane, depth). +/// to `fallback` first. → the edited objects and where the first one was (plane, depth). In an +/// interaction whose document is still its snapshot, the duplicates are the copies the interaction +/// keeps ([`super::object::preview_copies`]), unless the snapshot's grid lists attachments of its +/// own ([`stores_attachments`]). fn persp_edit( s: &mut Session, label: &str, @@ -951,9 +960,20 @@ fn persp_edit( return Err(EngineError::Other("select the objects to transform in perspective".into())); } let mut g = grid_of(&s.doc()?.doc); + let kept = if copy && !stores_attachments(&s.doc()?.doc) { super::object::preview_copies(s, ids)? } else { None }; s.edit(label, |d, sel| { g.adopt_stored_attachments(d); - let targets = if copy { duplicate_in(d, sel, ids, Affine::IDENTITY)? } else { ids.to_vec() }; + let targets = match &kept { + // The document becomes the snapshot with the kept copies, not yet mapped, and the loop + // below maps only the copies. + Some(k) => { + *d = Document::clone(&k.doc); + sel.clone_from(&k.selection); + k.ids.clone() + } + None if copy => duplicate_in(d, sel, ids, Affine::IDENTITY)?, + None => ids.to_vec(), + }; let mut first = None; for id in &targets { let (plane, depth) = match g.attachment_of(d, *id) { @@ -1139,12 +1159,32 @@ fn plane_move(s: &mut Session, p: &Value) -> Result { let ids = if objects == "move" { on_plane(&s.doc()?.doc) } else { vec![] }; return Ok(json!({ "plane": plane.id(), "offset": to, "ids": ids.iter().map(|i| i.0).collect::>() })); } + // In an interaction whose document is still its snapshot, the copies of the objects on the + // plane are the copies the interaction keeps (`preview_copies`), unless the snapshot's grid + // lists attachments of its own (`stores_attachments`). + let kept = if objects == "copy" && s.in_interaction() && !stores_attachments(&s.doc()?.doc) { + let on = on_plane(&s.doc()?.doc); + super::object::preview_copies(s, &on)? + } else { + None + }; let ids = s.edit(label, |d, sel| { old.adopt_stored_attachments(d); let mut ids = vec![]; if objects != "none" { - let on = on_plane(d); - ids = if objects == "copy" { duplicate_in(d, sel, &on, Affine::IDENTITY)? } else { on }; + ids = match &kept { + // The document becomes the snapshot with the kept copies, not yet moved, and the + // loop below moves only the copies. + Some(k) => { + *d = Document::clone(&k.doc); + sel.clone_from(&k.selection); + k.ids.clone() + } + None => { + let on = on_plane(d); + if objects == "copy" { duplicate_in(d, sel, &on, Affine::IDENTITY)? } else { on } + } + }; let m = old.transform_map(plane, from, Affine::IDENTITY, to - from).ok_or_else(beyond_horizon)?; for id in &ids { warp_checked(d, *id, &m)?; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index d1628b242..a41287441 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -348,14 +348,14 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } -/// Returns the copies of `roots` for a transform with `copy` in an interaction whose document is -/// still its snapshot, as in a preview ([`Session::preview`] puts the snapshot back first). These -/// are the copies the interaction keeps when they were made of that snapshot for the same `roots`; -/// otherwise this copies `roots` with [`duplicate_in`] in a clone of the snapshot, untransformed, -/// and the interaction keeps that clone. It returns `None` outside an interaction, and when the -/// document is no longer the snapshot, as for a command run in a typing session or a script batch -/// after an earlier edit there: the transform then copies the current document. -fn preview_copies(s: &mut Session, roots: &[NodeId]) -> Result>> { +/// Returns the copies of `roots` for a transform or a perspective edit with `copy` in an interaction +/// whose document is still its snapshot, as in a preview ([`Session::preview`] puts the snapshot +/// back first). These are the copies the interaction keeps when they were made of that snapshot for +/// the same `roots`; otherwise this copies `roots` with [`duplicate_in`] in a clone of the snapshot, +/// untransformed, and the interaction keeps that clone. It returns `None` outside an interaction, +/// and when the document is no longer the snapshot, as for a command run in a typing session or a +/// script batch after an earlier edit there: the command then copies the current document. +pub(crate) fn preview_copies(s: &mut Session, roots: &[NodeId]) -> Result>> { let st = s.doc_mut()?; let Some(it) = &mut st.interaction else { return Ok(None) }; if !Arc::ptr_eq(&st.doc, &it.doc) { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8f74a43aa..51f73a116 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -219,7 +219,8 @@ pub struct Interaction { /// The perspective transform the previews make (`perspective.transform` params): Transform /// Again repeats it once the drag is committed. pub perspective_again: Option, - /// A transform with `copy` keeps the copies it made of the snapshot here ([`PreviewCopies`]). + /// A transform or a perspective edit with `copy` keeps the copies it made of the snapshot here + /// ([`PreviewCopies`]). pub(crate) copies: Option>, } @@ -233,14 +234,15 @@ pub enum LastTransform { Each(Value), } -/// This holds the copies that a transform with `copy` made of an interaction's snapshot -/// ([`cmd::object::apply_transform`]). A later transform with `copy` of the same objects, while -/// the document is still that snapshot, starts from `doc` and transforms only the copies. +/// This holds the copies that a transform or a perspective edit with `copy` made of an +/// interaction's snapshot ([`cmd::object::apply_transform`]; `persp_edit` and `plane_move` in +/// `cmd::distortcmds`). A later transform or perspective edit with `copy` of the same objects, +/// while the document is still that snapshot, starts from `doc` and transforms only the copies. #[derive(Debug)] pub(crate) struct PreviewCopies { /// The copies were made from this snapshot. pub(crate) from: Arc, - /// The transform copied these objects. + /// The transform or perspective edit copied these objects. pub(crate) roots: Vec, /// This is `from` with the copies added, untransformed. pub(crate) doc: Arc, diff --git a/crates/engine/src/tests_large_edits.rs b/crates/engine/src/tests_large_edits.rs index 6a42f00f7..a402c1a23 100644 --- a/crates/engine/src/tests_large_edits.rs +++ b/crates/engine/src/tests_large_edits.rs @@ -66,7 +66,7 @@ fn in_turn(a: &[NodeId], b: &[NodeId]) -> Vec { } /// Runs `f` on `s` on a thread of its own and fails the test when it takes more than 20 s. -fn soon(mut s: Session, what: &'static str, f: impl FnOnce(&mut Session) -> T + Send + 'static) -> (Session, T) { +pub(crate) fn soon(mut s: Session, what: &'static str, f: impl FnOnce(&mut Session) -> T + Send + 'static) -> (Session, T) { let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let r = f(&mut s); diff --git a/crates/engine/src/tests_xform.rs b/crates/engine/src/tests_xform.rs index 11c162ba6..499c1598e 100644 --- a/crates/engine/src/tests_xform.rs +++ b/crates/engine/src/tests_xform.rs @@ -2,9 +2,13 @@ use serde_json::json; use vectorcraft_color::{Color, Paint}; -use vectorcraft_geom::{Point, Rect}; +use vectorcraft_doc::{Appearance, Node}; +use vectorcraft_geom::{Point, Rect, Vec2, shapes}; +use vectorcraft_tools::distort::perspective::{self as persp, Plane}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; +use super::tests_large_edits::{soon, undo_gives_back}; +use super::tests_persp_select::{bounds_of, grid, persp_session, plane_pts}; use super::*; fn session() -> Session { @@ -250,6 +254,201 @@ fn canceling_an_alt_drag_leaves_the_document_as_it_was() { assert!(s.doc().unwrap().interaction.is_none()); } +/// Returns a session with the grid and the square of [`persp_session`], the grid shown with its +/// right plane active, and a group of two squares attached to the right plane beside the square, +/// with the square and the group selected; and the square's id. +fn square_and_group_in_perspective() -> (Session, NodeId) { + let (mut s, square) = persp_session(); + let a = rect(&mut s, 525.0, 390.0, 20.0, 20.0); + let b = rect(&mut s, 550.0, 390.0, 20.0, 20.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let group = s.execute("object.group", &json!({})).unwrap()["id"].clone(); + s.execute("perspective.attach", &json!({"ids": [group], "plane": "right"})).unwrap(); + s.execute("perspective.grid.show", &json!({"visible": true})).unwrap(); + s.execute("perspective.plane.set", &json!({"plane": "right"})).unwrap(); + s.execute("select.set", &json!({"ids": [square.0, group]})).unwrap(); + (s, square) +} + +/// Returns the press and the three moves of an Alt-drag on [`square_and_group_in_perspective`]: from +/// the middle of `square`, 10 pt apart along the right plane, or with `widget`, from the right +/// plane's widget, 4 pt apart to the left. +fn persp_drag(s: &Session, square: NodeId, widget: bool) -> [(f64, f64); 4] { + let g = grid(s); + let xy = |p: Point| (p.x, p.y); + if widget { + let (_, w) = g.plane_widgets().into_iter().find(|(p, _)| *p == Plane::Right).unwrap(); + [0.0, 4.0, 8.0, 12.0].map(|dx| xy(w + Vec2::new(-dx, 0.0))) + } else { + let c = bounds_of(&plane_pts(s, square, Plane::Right, 0.0)).center(); + let h = g.homography(Plane::Right).unwrap(); + [0.0, 10.0, 20.0, 30.0].map(|du| xy(h.apply(c + Vec2::new(du, 0.0)).unwrap())) + } +} + +/// An Alt-drag of objects in perspective with the Perspective Selection tool, and an Alt-drag of a +/// plane widget with that tool or the Perspective Grid tool, copy the objects on the first move, and +/// the moves after it map those copies: the interaction keeps them, made once, until the drag ends. +#[test] +fn an_alt_drag_in_perspective_copies_the_objects_once() { + let kept = |s: &Session| s.doc().unwrap().interaction.as_ref().and_then(|it| it.copies.clone()); + for (tool, widget) in [("perspectiveSelection", false), ("perspectiveSelection", true), ("perspectiveGrid", true)] { + let what = if widget { "plane widget" } else { "objects" }; + let (mut s, square) = square_and_group_in_perspective(); + let [press, moves @ ..] = persp_drag(&s, square, widget); + s.select_tool(tool, ViewInfo::default()).unwrap(); + drag_at(&mut s, PointerKind::Down, press, true); + let mut first = None; + for at in moves { + drag_at(&mut s, PointerKind::Drag, at, true); + let k = kept(&s).unwrap_or_else(|| panic!("{tool}, {what}: the move to {at:?} keeps its copies")); + assert!(Arc::ptr_eq(first.get_or_insert_with(|| k.clone()), &k), "{tool}, {what}: the move to {at:?} maps the first move's copies"); + } + drag_at(&mut s, PointerKind::Up, moves[2], true); + assert!(s.doc().unwrap().interaction.is_none()); + assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 4, "{tool}, {what}: the objects and their copies"); + } +} + +/// Each move of an Alt-drag with the Perspective Selection tool leaves the document and the selected +/// objects that one command with that move's params gives: `perspective.move` for objects and +/// `perspective.plane.move` for a plane widget. A move with Alt released moves the objects (or the +/// plane alone), and the next move with Alt copies them again with the first copies' ids. The +/// release keeps the last move as one undo step, Undo gives back the document, and a drag canceled +/// after its moves leaves the document as it was. +#[test] +fn each_move_of_an_alt_drag_in_perspective_gives_what_one_command_gives() { + let by_one_command = |cmd: &str, params: &Value| { + let (mut t, _) = square_and_group_in_perspective(); + t.execute(cmd, params).unwrap(); + let st = t.doc().unwrap(); + (Document::clone(&st.doc), st.selection.objects.clone()) + }; + for (widget, label) in [(false, "Copy in Perspective"), (true, "Move Plane")] { + let (mut s, square) = square_and_group_in_perspective(); + let (before, originals, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.objects.clone(), undo_len(&s)); + let [press, moves @ ..] = persp_drag(&s, square, widget); + s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap(); + drag_at(&mut s, PointerKind::Down, press, true); + let mut copies = None; + for (at, alt) in moves.into_iter().zip([true, false, true]) { + drag_at(&mut s, PointerKind::Drag, at, alt); + let (cmd, params) = previewed(&s); + assert!(shows(&s, &by_one_command(&cmd, ¶ms)), "{label}: the move to {at:?}, Alt: {alt}"); + let selected = s.doc().unwrap().selection.objects.clone(); + if alt { + assert_eq!(copies.get_or_insert_with(|| selected.clone()), &selected, "{label}: the copies keep their ids"); + } else { + assert_eq!(selected, originals, "{label}: the objects themselves"); + } + } + drag_at(&mut s, PointerKind::Up, moves[2], true); + let (cmd, params) = s.journal.last().cloned().unwrap(); + assert!(shows(&s, &by_one_command(&cmd, ¶ms)), "{label}: the release"); + assert_eq!(undo_len(&s), undo + 1); + assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some(label)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(*s.doc().unwrap().doc == *before, "{label}: Undo gives back the document"); + let doc = s.doc().unwrap().doc.clone(); + drag_at(&mut s, PointerKind::Down, press, true); + for at in moves { + drag_at(&mut s, PointerKind::Drag, at, true); + } + s.cancel_interaction().unwrap(); + assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &doc), "{label}: Cancel puts back the document"); + assert_eq!(s.doc().unwrap().selection.objects, originals); + assert_eq!(undo_len(&s), undo); + } +} + +/// On a grid as files from before attachments were kept on the objects store it (the grid lists the +/// attachments, and the objects have none of their own), each move of an Alt-drag with the +/// Perspective Selection tool, of the objects or of a plane widget, leaves what one command with +/// that move's params gives, with the copies attached to the right plane. +#[test] +fn an_alt_drag_in_perspective_on_a_grid_from_an_older_file_attaches_the_copies() { + let older = || { + let (s, square) = square_and_group_in_perspective(); + let ids = s.doc().unwrap().selection.objects.clone(); + let mut d = Document::clone(&s.doc().unwrap().doc); + let mut attached = serde_json::Map::new(); + for id in &ids { + persp::release(d.node_mut(*id).unwrap()); + attached.insert(id.0.to_string(), json!("right")); + } + d.unknown.get_mut(persp::DOC_KEY).unwrap()["attached"] = Value::Object(attached); + let mut t = Session::new(); + t.add_document(d, None); + t.execute("select.set", &json!({"ids": ids})).unwrap(); + (t, square) + }; + for (what, widget) in [("objects", false), ("plane widget", true)] { + let (mut s, square) = older(); + let [press, moves @ ..] = persp_drag(&s, square, widget); + s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap(); + drag_at(&mut s, PointerKind::Down, press, true); + for at in moves { + drag_at(&mut s, PointerKind::Drag, at, true); + let (cmd, params) = previewed(&s); + let (mut t, _) = older(); + t.execute(&cmd, ¶ms).unwrap(); + let st = t.doc().unwrap(); + assert!(shows(&s, &(Document::clone(&st.doc), st.selection.objects.clone())), "{what}: the move to {at:?}"); + let d = &s.doc().unwrap().doc; + for copy in &s.doc().unwrap().selection.objects { + assert_eq!(persp::attachment(d.node(*copy).unwrap()).map(|a| a.0), Some(Plane::Right), "{what}: the copy {copy:?} is attached"); + } + } + } +} + +/// With Alt held, a Perspective Selection tool press on one of 30,000 squares on the left plane, +/// four moves of 20 pt and the release copy the squares once: each move maps the copies that the +/// first one kept, and the drag leaves what one `perspective.move` with copy leaves, as one undo +/// step. +#[test] +fn alt_drag_in_perspective_of_30000_objects_finishes_within_20_s() { + // The squares are 6 pt wide and 10 pt apart, in rows of 150, between the left vanishing point + // of the two-point grid and its origin. + let on_the_left = |d: &mut Document, k: usize| { + let (x, y) = (300.0 + (k % 150) as f64 * 10.0, 100.0 + (k / 150) as f64 * 10.0); + Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, y, x + 6.0, y + 6.0)), Appearance::default_art()) + }; + let in_perspective = || { + let mut s = super::tests_large_edits::session(30_000, on_the_left); + s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap(); + s.execute("perspective.attach", &json!({"plane": "left"})).unwrap(); + s + }; + let s = in_perspective(); + let before = s.doc().unwrap().doc.clone(); + // The press lands on a square in the middle of the art. + let grab = bounds(&s, s.doc().unwrap().selection.objects[15_075]).center(); + let (mut s, kept) = soon(s, "the Alt-drag in perspective", move |s| { + let (v, alt) = (ViewInfo::default(), Mods { alt: true, ..Mods::default() }); + s.select_tool("perspectiveSelection", v)?; + let at = |kind, dx: f64| PointerEvent::new(kind, grab.x + dx, grab.y).with_mods(alt); + s.pointer(&at(PointerKind::Down, 0.0), v)?; + let mut kept = vec![]; + for dx in [20.0, 40.0, 60.0, 80.0] { + s.pointer(&at(PointerKind::Drag, dx), v)?; + kept.push(s.doc()?.interaction.as_ref().and_then(|it| it.copies.clone())); + } + s.pointer(&at(PointerKind::Up, 80.0), v)?; + Ok::<_, EngineError>(kept) + }); + let kept = kept.unwrap(); + let first = kept[0].clone().expect("the first move keeps its copies"); + assert!(kept.iter().all(|k| k.as_ref().is_some_and(|k| Arc::ptr_eq(k, &first))), "each move maps the copies the first one kept"); + let (cmd, params) = s.journal.last().cloned().unwrap(); + let mut want = in_perspective(); + want.execute(&cmd, ¶ms).unwrap(); + assert!(*s.doc().unwrap().doc == *want.doc().unwrap().doc, "the document one perspective.move with copy leaves"); + assert!(s.doc().unwrap().selection.objects == want.doc().unwrap().selection.objects, "the copies are selected"); + assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Copy in Perspective")); + undo_gives_back(&mut s, &before); +} + #[test] fn rotate_tool_alt_click_requests_dialog() { let mut s = session(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 94c2c8465..034a36b93 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -586,6 +586,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Delete, Cut, Group, Ungroup, Make and Release Compound Path, Make Clipping Mask, Send to Current Layer, Duplicate, Copy in the Move, Rotate, Scale, Reflect and Shear dialogs, Transform Each with Copy and each move of an Alt-drag take time in proportion to the size of the document (#1429). In the web build the status bar's render and UI times are measured with the browser's `performance.now()`, and the Layers panel stops rendering row thumbnails in a frame once they have taken 8 ms. An Alt-drag with the Selection, Direct Selection, Group Selection, Rotate, Scale, Reflect or Shear tool copies the selection on its first move, and the moves after it transform those copies; the previews of the Move, Rotate, Scale, Reflect and Shear dialogs with Copy and Preview on copy the selection once in the same way (#1429). + An Alt-drag of objects in perspective with the Perspective Selection tool, and an Alt-drag of a plane widget with that tool or the Perspective Grid tool, copy the objects on the first move, and the moves after it move those copies (#1429). When the desktop app opens a document on a thread of its own (#1245), that thread also reads the document's linked files and lays out its type, and the UI thread only adds the finished document: in release builds, the step left on the UI thread takes 0.02 ms instead of 1.04 s for a document with 12 linked 3000 × 2000 TIFFs, and it takes 0.1 ms instead of 0.85 s for one with 300 Auto Size area type objects, every second one in three columns. The Layers panel's Delete Selection, Duplicate Selection, Collect in New Layer, Merge Selected, Flatten Artwork, Release to Layers (Sequence) and row drags also take time in proportion to the size of the document, and so do Cut Artboard and Duplicate Artboard with their art and each move of an Alt-drag of one artboard with the Artboard tool while Move/Copy Artwork with Artboard is on (#1429). In Outline view the renderer draws paths and compound paths from the geometry it keeps for them between frames, and it culls the members of clip groups by the bounds it keeps for them. Layers with Preview off and the outlines of Image Trace objects are drawn the same way.