Object: Lock, Transform Each, Perspective and other commands that change nothing record no undo step

These commands edited the document even when nothing changed, so the
call recorded an undo step, emptied Redo and marked the document
modified:

- All Artwork Above and Other Layers (Object › Lock and
  Object › Hide) with nothing to lock or hide: no object above the
  selection, no other layer, or every one already locked (Lock) or
  hidden (Hide).
- Transform Each with no object to transform, and without Copy when
  both scales are 100% and there is no move, rotation or reflection.
- Rearrange All Artboards when no artboard moves, or with no artboard.
- stroke.widthPoint.set and stroke.widthPoint.copy when the profile
  comes out as it was, and stroke.widthProfile.set with the profile the
  strokes have already.
- Liquify when the stroke reshapes no path, and Puppet Warp given the
  pins the art holds already (rest: true), or without rest when every
  pin stays where it is and turns the art by 0° or is free.
- Define Perspective Grid with the grid the document has, Attach to
  Active Plane with no object, Release with Perspective on art that is
  not in perspective, Move Plane to where the plane is, without copies,
  and Move Plane to Match Object when the plane is there and active
  already.
- Move, Nudge and Transform in Perspective without Copy when no object
  moves (1e-9 pt or less along the plane or its normal, Snap to Grid
  included, or the identity), and Transform Again after one.

These commands now return without editing the document. The replies
have the same form as before. Transform Each with Copy and no object
to transform no longer empties the selection. Puppet Warp without rest,
with every pin where it is and turning the art by 0° or free, no longer
turns a live shape into a plain path. All Artwork Above still deselects
selected objects that are hidden or locked. Width Point counts a change
of 1e-9 or less in a point's place or width factor (its width over half
the stroke weight) as none. OK in Width Point Edit with the widths it
shows, with or without Adjust Adjoining Width Points, records no undo
step. After a move in perspective that moved nothing, Transform Again
repeats the identity.

One line in the evidence list of docs/target-app-parity.md names the
commands.
This commit is contained in:
dotCooCoo
2026-10-12 00:15:59 +07:00
committed by bflatastic
parent bab78767ca
commit cf583304d1
6 changed files with 520 additions and 32 deletions
+146 -27
View File
@@ -266,6 +266,17 @@ fn adjust_adjoining(points: &mut [(f64, f64, f64)], i: usize, old: (f64, f64, f6
}
}
/// Whether `a` and `b` hold the same width points, each place and width within a rounding error
/// (1e-9).
fn same_points(a: Option<&WidthProfile>, b: Option<&WidthProfile>) -> bool {
let near = |p: &(f64, f64, f64), q: &(f64, f64, f64)| (p.0 - q.0).abs() <= 1e-9 && (p.1 - q.1).abs() <= 1e-9 && (p.2 - q.2).abs() <= 1e-9;
match (a, b) {
(None, None) => true,
(Some(a), Some(b)) => a.points.len() == b.points.len() && a.points.iter().zip(&b.points).all(|(p, q)| near(p, q)),
_ => false,
}
}
fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "stroke.widthPoint.set";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
@@ -275,12 +286,16 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "left/right must be non-negative widths"));
}
let adjust = bool_or(p, "adjustAdjoining", false);
let pos = s.edit("Width Point", |d, _| {
// When the profile comes out as it was, within a rounding error (1e-9), the document and its undo
// history stay as they are.
let mut pos = 0;
super::overprint::edit_counted(s, "Width Point", |d| {
let st = weighted_stroke(d, id)?;
let before = st.profile.clone();
let half = st.width / 2.0;
let mut prof = st.profile.take().unwrap_or_else(uniform);
let pt = (t, left / half, right / half);
let pos = match point_index(p, &prof, C)? {
pos = match point_index(p, &prof, C)? {
Some(i) => {
let old = prof.points[i];
if adjust {
@@ -301,7 +316,7 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
}
};
st.profile = Some(prof);
Ok(pos)
Ok(usize::from(!same_points(st.profile.as_ref(), before.as_ref())))
})?;
Ok(json!({ "index": pos }))
}
@@ -310,14 +325,18 @@ fn width_point_copy(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "stroke.widthPoint.copy";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let t = f64_req(p, "t", C)?.clamp(0.0, 1.0);
let pos = s.edit("Copy Width Point", |d, _| {
// When the profile comes out as it was (the copy replaces a side of a discontinuous point that has
// its widths already), the document and its undo history stay as they are.
let mut pos = 0;
super::overprint::edit_counted(s, "Copy Width Point", |d| {
let st = weighted_stroke(d, id)?;
let before = st.profile.clone();
let mut prof = st.profile.take().unwrap_or_else(uniform);
let i = point_index(p, &prof, C)?.ok_or_else(|| bad(C, "missing index"))?;
let (from, l, r) = prof.points[i];
let pos = place(&mut prof.points, (t, l, r), from < t);
pos = place(&mut prof.points, (t, l, r), from < t);
st.profile = Some(prof);
Ok(pos)
Ok(usize::from(st.profile != before))
})?;
Ok(json!({ "index": pos }))
}
@@ -373,6 +392,11 @@ fn width_profile_set(s: &mut Session, p: &Value) -> Result<Value> {
let mut ids: Vec<NodeId> = targets(s, p)?.into_iter().map(|id| stroke_owner(doc, id)).collect();
ids.sort_unstable();
ids.dedup();
// When every stroke has the profile already, or there is no stroke, the document and its undo
// history stay as they are.
if ids.iter().all(|id| doc.node(*id).is_some_and(|n| n.appearance.stroke().is_none_or(|st| st.profile == profile))) {
return ok();
}
s.edit("Width Profile", |d, _| {
for id in &ids {
let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?;
@@ -602,18 +626,23 @@ fn liquify(s: &mut Session, p: &Value) -> Result<Value> {
stroke.extend(pts.get(done..).unwrap_or_default());
let results = stroke.results();
let (skipped, warning) = stroke.skipped();
let changed = s.edit(&format!("{} Tool", prm.kind.label()), |d, _| {
let mut changed = vec![];
for (id, new) in results {
let Some(n) = d.node_mut(id) else { continue };
if let NodeKind::Path { path, live, .. } = &mut n.kind {
*path = new;
*live = None;
changed.push(id.0);
// When the stroke reshapes no path, the document and its undo history stay as they are.
let changed = if results.is_empty() {
vec![]
} else {
s.edit(&format!("{} Tool", prm.kind.label()), |d, _| {
let mut changed = vec![];
for (id, new) in results {
let Some(n) = d.node_mut(id) else { continue };
if let NodeKind::Path { path, live, .. } = &mut n.kind {
*path = new;
*live = None;
changed.push(id.0);
}
}
}
Ok(changed)
})?;
Ok(changed)
})?
};
if s.in_interaction() {
s.liquify_stroke = Some(stroke);
}
@@ -659,10 +688,21 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
let ids_json = json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() });
if rest {
let pins: Vec<PuppetPin> = pins.iter().zip(&moved).zip(angles).map(|((r, a), angle)| PuppetPin { rest: *r, at: *a, angle }).collect();
s.edit("Puppet Warp", |d, _| warp_from_rest(d, &ids, pins, expand).map_err(|e| bad(C, e)))?;
// When the art and its pins come out as they were, the document and its undo history stay
// as they are.
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, "Puppet Warp", |d| {
warp_from_rest(d, &ids, pins, expand).map_err(|e| bad(C, e))?;
Ok(usize::from(d.puppet != before.puppet || ids.iter().any(|id| d.node(*id) != before.node(*id))))
})?;
return Ok(ids_json);
}
let mesh = mesh_for(&s.doc()?.doc, &ids, expand).ok_or_else(|| EngineError::Other("nothing to warp".into()))?;
// When every pin stays where it is and turns the art by 0° or is free, the document and its undo
// history stay as they are.
if pins == moved && angles.iter().all(|a| a.is_none_or(|a| a == 0.0)) {
return Ok(ids_json);
}
let pins: Vec<arap::Pin> = pins.iter().zip(&moved).zip(angles).map(|((a, b), angle)| arap::Pin { angle, ..arap::Pin::new(*a, *b) }).collect();
let deformed = arap::deform(&mesh, &pins);
let f = |q: Point| mesh.map(&deformed, q);
@@ -707,6 +747,14 @@ pub(crate) fn store_grid(d: &mut Document, g: &PerspectiveGrid) {
g.store(d);
}
/// Whether `d` holds `g` as [`store_grid`] writes it (with `attached` cleared): then storing `g`
/// leaves `d` as it is.
fn grid_kept(d: &Document, g: &PerspectiveGrid) -> bool {
let mut g = g.clone();
g.attached.clear();
serde_json::to_value(&g).is_ok_and(|v| d.unknown.get(persp::DOC_KEY) == Some(&v))
}
/// Change view state of the grid without an undo step.
pub(crate) fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> Result<PerspectiveGrid> {
let st = s.doc_mut()?;
@@ -724,6 +772,11 @@ fn grid_set(s: &mut Session, p: &Value) -> Result<Value> {
let old = grid_of(&s.doc()?.doc);
let g = old.merged(p).map_err(|e| bad("perspective.grid.set", e))?;
let reproject = bool_or(p, "reproject", false);
// When the document holds this grid already, no object moves, and the document and its undo
// history stay as they are.
if grid_kept(&s.doc()?.doc, &g) {
return Ok(g.definition_json());
}
s.edit("Define Perspective Grid", |d, _| {
if reproject {
old.adopt_stored_attachments(d);
@@ -800,6 +853,11 @@ fn attach(s: &mut Session, p: &Value) -> Result<Value> {
let ids = roots(s, p)?;
let mut g = grid_of(&s.doc()?.doc);
let plane = plane_param(&g, p, "perspective.attach")?;
// With no object to attach, when the document holds the grid already, the document and its undo
// history stay as they are.
if ids.is_empty() && grid_kept(&s.doc()?.doc, &g) {
return Ok(json!({ "ids": [], "plane": plane.id() }));
}
s.edit("Attach to Active Plane", |d, _| {
for id in &ids {
attach_in(d, &mut g, *id, plane, false, None)?;
@@ -813,6 +871,19 @@ fn attach(s: &mut Session, p: &Value) -> Result<Value> {
fn release(s: &mut Session, p: &Value) -> Result<Value> {
let ids = roots(s, p)?;
let mut g = grid_of(&s.doc()?.doc);
// When releasing leaves every object as it is (none is in perspective) and the document holds
// the grid already, the document and its undo history stay as they are.
let d = &s.doc()?.doc;
let unchanged = |id: &NodeId| {
d.node(*id).is_none_or(|n| {
let mut released = n.clone();
persp::release(&mut released);
released == *n
})
};
if ids.iter().all(unchanged) && grid_kept(d, &g) {
return ok();
}
s.edit("Release with Perspective", |d, _| {
for id in &ids {
g.attached.remove(&id.0.to_string());
@@ -875,6 +946,19 @@ fn persp_edit(
})
}
/// Where the first of `ids` is in perspective (its plane and depth) when `ids` is not empty, every one
/// of them is in perspective already, `still` holds for each one's plane and depth, and the document
/// holds the grid already; None otherwise.
fn unmoved(s: &Session, ids: &[NodeId], still: impl Fn(&PerspectiveGrid, Plane, f64) -> bool) -> Result<Option<Attached>> {
let d = &s.doc()?.doc;
let g = grid_of(d);
if ids.is_empty() || !grid_kept(d, &g) {
return Ok(None);
}
let at: Option<Vec<Attached>> = ids.iter().map(|id| g.attachment_of(d, *id).filter(|(pl, depth)| still(&g, *pl, *depth))).collect();
Ok(at.and_then(|at| at.first().copied()))
}
/// Remember `again` (`perspective.transform` params) for Object › Transform › Transform Again, once
/// the drag that made it is committed.
fn record_again(s: &mut Session, again: Value) -> Result<()> {
@@ -921,11 +1005,25 @@ fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
}
};
let label = if copy { "Copy in Perspective" } else { "Move in Perspective" };
let (targets, first) = persp_edit(s, label, &ids, fallback, copy, &step)?;
// Transform Again repeats the first object's move.
// When no object moves more than 1e-9 pt along its plane's axes or its normal (Snap to Grid
// included) and there is no copy, the document and its undo history stay as they are.
let still = |g: &PerspectiveGrid, pl: Plane, depth: f64| {
if perp {
g.depth_at(pl, depth, from, to).is_some_and(|d| (d - depth).abs() <= 1e-9)
} else {
g.plane_delta(pl, depth, from, to).map(|dv| snapped(pl, depth, dv)).is_some_and(|dv| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9)
}
};
let first_still = if copy { None } else { unmoved(s, &ids, still)? };
let (targets, first) = match first_still {
Some(at) => (ids, Some(at)),
None => persp_edit(s, label, &ids, fallback, copy, &step)?,
};
// Transform Again repeats the first object's move, and the identity when no object moved.
if let Some((pl, depth)) = first {
let g = grid_of(&s.doc()?.doc);
let again = match perp {
_ if first_still.is_some() => Some(json!({"matrix": matrix_json(Affine::IDENTITY), "copy": copy})),
true => g.depth_at(pl, depth, from, to).map(|d| json!({"matrix": matrix_json(Affine::IDENTITY), "depth": d - depth, "copy": copy})),
false => {
g.plane_delta(pl, depth, from, to).map(|dv| json!({"matrix": matrix_json(Affine::translate(snapped(pl, depth, dv))), "copy": copy}))
@@ -955,7 +1053,14 @@ fn persp_transform(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok((g.transform_map(pl, depth, m, dz).ok_or_else(beyond_horizon)?, to))
};
let (targets, _) = persp_edit(s, if copy { "Copy in Perspective" } else { "Transform in Perspective" }, &ids, fallback, copy, &step)?;
// An identity without a copy moves no object that is in perspective already: then the document
// and its undo history stay as they are, and Transform Again repeats the identity.
let identity = !copy && m == Affine::IDENTITY && dz == 0.0;
let targets = if identity && unmoved(s, &ids, |g, pl, depth| g.transform_map(pl, depth, m, dz).is_some())?.is_some() {
ids
} else {
persp_edit(s, if copy { "Copy in Perspective" } else { "Transform in Perspective" }, &ids, fallback, copy, &step)?.0
};
record_again(s, json!({"matrix": matrix_json(m), "depth": dz, "copy": copy}))?;
Ok(ids_json(&targets))
}
@@ -992,16 +1097,25 @@ fn plane_move(s: &mut Session, p: &Value) -> Result<Value> {
"move" => "Move Plane and Objects",
_ => "Move Plane",
};
// The objects on the plane where it was (not those moved off it along its normal).
let on_plane = |d: &Document| -> Vec<NodeId> {
persp::attached_roots(d)
.into_iter()
.filter(|(id, pl, depth)| *pl == plane && (depth - from).abs() < 1e-6 && d.is_editable(*id))
.map(|(id, ..)| id)
.collect()
};
// A plane moved to where it is, with its objects or without them, moves nothing: when the
// document holds the grid already, the document and its undo history stay as they are.
if to == from && objects != "copy" && grid_kept(&s.doc()?.doc, &g) {
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::<Vec<_>>() }));
}
let ids = s.edit(label, |d, sel| {
old.adopt_stored_attachments(d);
let mut ids = vec![];
if objects != "none" {
// The objects on the plane where it was (not those moved off it along its normal).
let on: Vec<NodeId> = persp::attached_roots(d)
.into_iter()
.filter(|(id, pl, depth)| *pl == plane && (depth - from).abs() < 1e-6 && d.is_editable(*id))
.map(|(id, ..)| id)
.collect();
let on = on_plane(d);
ids = 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 {
@@ -1025,6 +1139,11 @@ fn plane_match(s: &mut Session, p: &Value) -> Result<Value> {
g.set_offset(plane, depth);
g.plane = plane;
g.validate().map_err(|e| bad("perspective.plane.matchObject", e))?;
// When the plane is at the object and active already, and the document holds that grid, the
// document and its undo history stay as they are.
if grid_kept(&st.doc, &g) {
return Ok(json!({ "plane": plane.id(), "offset": depth }));
}
s.edit("Move Plane to Match Object", |d, _| {
store_grid(d, &g);
Ok(())
+29 -5
View File
@@ -226,6 +226,13 @@ fn above(s: &mut Session, lock: bool) -> Result<Value> {
}
}
}
// When there is no object above, or every one is locked (Lock) or hidden (Hide) already, the
// document and its undo history stay as they are, and selected objects that are hidden or
// locked are deselected.
if hits.iter().all(|id| d.node(*id).is_none_or(|n| if lock { n.locked } else { !n.visible })) {
s.select(|d, sel| sel.deselect_uneditable(d))?;
return Ok(json!({ "count": hits.len() }));
}
let label = if lock { "Lock" } else { "Hide" };
let n = hits.len();
s.edit(label, |d, sel| {
@@ -249,6 +256,11 @@ fn other_layers(s: &mut Session, lock: bool) -> Result<Value> {
let keep: Vec<NodeId> = st.selection.objects.iter().filter_map(|id| st.doc.ancestry(*id)).flatten().collect();
let others: Vec<NodeId> = st.doc.layers.iter().map(|l| l.id).filter(|l| !keep.contains(l)).collect();
let n = others.len();
// When there is no other layer, or every one is locked (Lock) or hidden (Hide) already, the
// document and its undo history stay as they are.
if others.iter().all(|id| st.doc.node(*id).is_none_or(|l| if lock { l.locked } else { !l.visible })) {
return Ok(json!({ "count": n }));
}
s.edit(if lock { "Lock Others" } else { "Hide Others" }, |d, _| {
for id in &others {
if let Some(n) = d.node_mut(*id) {
@@ -300,6 +312,12 @@ fn transform_each(s: &mut Session, p: &Value) -> Result<Value> {
let mut rng = Rng(p.get("seed").and_then(Value::as_u64).unwrap_or(0x9E37_79B9_7F4A_7C15).max(1));
let mut sc = if sh != 100.0 || sv != 100.0 { super::object::scaling(s, p) } else { Default::default() };
sc.patterns = super::object::transform_patterns(s, p, &roots)?;
// With no object to transform, or without a copy when both scales are 100% and there is no
// move, rotation or reflection (with or without Random), the document and its undo history
// stay as they are.
if roots.is_empty() || (!copy && sh == 100.0 && sv == 100.0 && mh == 0.0 && mv == 0.0 && rot == 0.0 && !rx && !ry) {
return Ok(ids_json(&roots));
}
let ids = s.edit("Transform Each", |d, sel| {
let targets = if copy { duplicate_in(d, sel, &roots, Affine::IDENTITY)? } else { roots.clone() };
for id in &targets {
@@ -853,12 +871,18 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
let move_art = bool_or(p, "moveArtwork", true);
let locked_and_hidden = s.prefs.move_locked_with_artboard;
let scale_strokes = false;
// How far each artboard moves: the grid's top-left is at the first artboard's.
let rects: Vec<Rect> = s.doc()?.doc.artboards.iter().map(|a| a.rect).collect();
let sizes: Vec<(f64, f64)> = rects.iter().map(|r| (r.width(), r.height())).collect();
let origin = rects.first().map_or(Point::ZERO, |r| r.origin());
let origins = super::newdoc::grid_origins_rc(&sizes, origin, (rows, cols), spacing, layout == L::GridByColumn, rtl);
let deltas: Vec<Vec2> = rects.iter().zip(origins).map(|(r, o)| o - r.origin()).collect();
// When no artboard moves more than 1e-9 pt on either axis, or there is none, the document and
// its undo history stay as they are.
if deltas.iter().all(|dl| dl.x.abs() <= 1e-9 && dl.y.abs() <= 1e-9) {
return Ok(json!({ "artboards": count, "rows": rows, "columns": cols }));
}
s.edit("Rearrange Artboards", |d, _| {
let rects: Vec<Rect> = d.artboards.iter().map(|a| a.rect).collect();
let Some(first) = rects.first().copied() else { return Ok(()) };
let sizes: Vec<(f64, f64)> = rects.iter().map(|r| (r.width(), r.height())).collect();
let origins = super::newdoc::grid_origins_rc(&sizes, first.origin(), (rows, cols), spacing, layout == L::GridByColumn, rtl);
let deltas: Vec<Vec2> = rects.iter().zip(origins).map(|(r, o)| o - r.origin()).collect();
if move_art {
let tops: Vec<(NodeId, Point)> = d
.layers