mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
1 parent
bab78767ca
commit
cf583304d1
6 files changed
+520
-32
No files matched your search
@@ -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(())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -37,6 +37,20 @@ fn undo_len(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
/// How many steps there are to undo and to redo, and whether the document has unsaved changes.
|
||||
fn history(s: &Session) -> (usize, usize, bool) {
|
||||
let st = s.doc().unwrap();
|
||||
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
|
||||
}
|
||||
|
||||
/// Leave a step to redo (a rectangle drawn and undone, which keeps the selection) and mark the
|
||||
/// document saved.
|
||||
fn with_a_step_to_redo(s: &mut Session) {
|
||||
rect(s, 700.0, 500.0, 10.0, 10.0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
}
|
||||
|
||||
fn gesture(s: &mut Session, tool: &str, pts: &[(f64, f64)], mods: Mods) {
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool(tool, v).unwrap();
|
||||
@@ -112,6 +126,58 @@ fn width_tool_gesture_is_one_undo_step() {
|
||||
assert_eq!(profile(&s, id).unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// Width Point with the place and widths a point has already (given its index, or at its place, with
|
||||
/// Adjust Adjoining Width Points, or as Width Point Edit gives back the widths it shows), Copy Width
|
||||
/// Point onto the side of a discontinuous point that has its widths already, and Width Profile with
|
||||
/// the profile the strokes have already (or null for a uniform stroke and for art with no stroke)
|
||||
/// record no undo step: the step to redo stays and the document stays unmodified.
|
||||
#[test]
|
||||
fn width_commands_that_change_nothing_record_no_undo_step() {
|
||||
let mut s = session();
|
||||
let id = line(&mut s);
|
||||
let uniform = line(&mut s);
|
||||
let r = rect(&mut s, 100.0, 400.0, 20.0, 20.0);
|
||||
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
|
||||
let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
// Points 1 and 2 make a discontinuous point at 0.5 whose sides are both 2 pt and 3 pt wide
|
||||
// (point 1 copied onto its own place), and point 3 is at 0.75.
|
||||
s.execute("stroke.widthPoint.set", &json!({"id": id.0, "t": 0.5, "left": 2, "right": 3})).unwrap();
|
||||
s.execute("stroke.widthPoint.copy", &json!({"id": id.0, "index": 1, "t": 0.5})).unwrap();
|
||||
s.execute("stroke.widthPoint.set", &json!({"id": id.0, "t": 0.75, "left": 4, "right": 4})).unwrap();
|
||||
let points = profile(&s, id).unwrap();
|
||||
assert_eq!(points, vec![(0.0, 1.0, 1.0), (0.5, 0.4, 0.6), (0.5, 0.4, 0.6), (0.75, 0.8, 0.8), (1.0, 1.0, 1.0)]);
|
||||
// Width Point Edit shows a width as its factor times half the weight (5 pt) and gives that back.
|
||||
// For this factor the round trip changes the last bit.
|
||||
let dragged = line(&mut s);
|
||||
let f = 1.8822996672167678;
|
||||
assert_ne!(f * 5.0 / 5.0, f);
|
||||
s.execute("stroke.widthProfile.set", &json!({"ids": [dragged.0], "points": [[0, 1, 1], [0.5, f, f], [1, 1, 1]]})).unwrap();
|
||||
with_a_step_to_redo(&mut s);
|
||||
let before = history(&s);
|
||||
assert_eq!((before.1, before.2), (1, false), "a step to redo, and nothing unsaved");
|
||||
let calls = [
|
||||
("stroke.widthPoint.set", json!({"id": id.0, "index": 1, "t": 0.5, "left": 2, "right": 3}), json!({"index": 1})),
|
||||
("stroke.widthPoint.set", json!({"id": id.0, "t": 0.75, "left": 4, "right": 4}), json!({"index": 3})),
|
||||
// Adjust Adjoining multiplies point 2's 0.4 by 0.8 and divides the product by 0.8, which
|
||||
// gives 0.4000000000000001.
|
||||
("stroke.widthPoint.set", json!({"id": id.0, "index": 3, "t": 0.75, "left": 4, "right": 4, "adjustAdjoining": true}), json!({"index": 3})),
|
||||
("stroke.widthPoint.set", json!({"id": dragged.0, "index": 1, "t": 0.5, "left": f * 5.0, "right": f * 5.0}), json!({"index": 1})),
|
||||
("stroke.widthPoint.copy", json!({"id": id.0, "index": 2, "t": 0.5}), json!({"index": 2})),
|
||||
("stroke.widthProfile.set", json!({"ids": [id.0], "points": points}), json!(null)),
|
||||
("stroke.widthProfile.set", json!({"ids": [uniform.0, group.0], "points": null}), json!(null)),
|
||||
];
|
||||
for (c, p, reply) in calls {
|
||||
assert_eq!(s.execute(c, &p).unwrap(), reply, "{c} {p}");
|
||||
assert_eq!(history(&s), before, "{c} {p}");
|
||||
assert_eq!(profile(&s, id).unwrap(), points, "{c} {p}");
|
||||
assert_eq!(s.journal.last().unwrap().0, c);
|
||||
}
|
||||
assert_eq!(profile(&s, dragged).unwrap()[1], (0.5, f, f));
|
||||
// Other widths are one undo step, and they clear Redo.
|
||||
s.execute("stroke.widthPoint.set", &json!({"id": id.0, "index": 1, "t": 0.5, "left": 3, "right": 3})).unwrap();
|
||||
assert_eq!(history(&s), (before.0 + 1, 0, true));
|
||||
}
|
||||
|
||||
// ---------- liquify ----------
|
||||
|
||||
fn warp_params() -> serde_json::Value {
|
||||
@@ -171,6 +237,23 @@ fn liquify_tool_replays_from_the_journal() {
|
||||
assert_eq!(path(&t, a), after);
|
||||
}
|
||||
|
||||
/// A Liquify stroke that reshapes no path (the brush reaches none) records no undo step, run as a
|
||||
/// command or dragged with the tool, and the command replies with no ids.
|
||||
#[test]
|
||||
fn liquify_that_reshapes_no_path_records_no_undo_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0);
|
||||
with_a_step_to_redo(&mut s);
|
||||
let before = history(&s);
|
||||
let r = s.execute("object.liquify", &json!({"tool": "warp", "points": [[600, 450], [640, 450]], "diameter": 50})).unwrap();
|
||||
assert_eq!(r["ids"], json!([]));
|
||||
assert_eq!(history(&s), before, "object.liquify");
|
||||
gesture(&mut s, "warp", &[(600.0, 450.0), (620.0, 450.0), (640.0, 450.0)], Mods::default());
|
||||
assert_eq!(s.journal.last().unwrap().0, "object.liquify");
|
||||
assert_eq!(history(&s), before, "a Warp tool drag");
|
||||
assert_eq!(path(&s, a).bounds().unwrap(), Rect::new(100.0, 100.0, 300.0, 300.0));
|
||||
}
|
||||
|
||||
fn session_with_rect() -> Session {
|
||||
let mut s = session();
|
||||
rect(&mut s, 100.0, 100.0, 200.0, 200.0);
|
||||
@@ -225,6 +308,40 @@ fn puppet_tool_drag_warps_selection() {
|
||||
assert!(path(&s, a).bounds().unwrap().y0 < 95.0);
|
||||
}
|
||||
|
||||
/// Puppet Warp with the pins the art holds already (as object.puppetWarp.pins lists them), and
|
||||
/// Puppet Warp with every pin left where it is and turning the art by 0° or free, record no undo
|
||||
/// step and leave the art as it is: a live shape stays live.
|
||||
#[test]
|
||||
fn puppet_warp_that_moves_nothing_records_no_undo_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 300.0, 100.0);
|
||||
let b = rect(&mut s, 100.0, 300.0, 300.0, 100.0);
|
||||
// The automatic pins, the right end's moved up 30 pt, warp the art in one undo step.
|
||||
let mut p = s.execute("object.puppetWarp.pins", &json!({"id": a.0})).unwrap();
|
||||
let moved = p["moved"].as_array().unwrap();
|
||||
let right = (0..moved.len()).max_by(|&i, &j| moved[i][0].as_f64().unwrap().total_cmp(&moved[j][0].as_f64().unwrap())).unwrap();
|
||||
let y = p["moved"][right][1].as_f64().unwrap();
|
||||
p["moved"][right][1] = json!(y - 30.0);
|
||||
let undo = undo_len(&s);
|
||||
s.execute("object.puppetWarp", &p).unwrap();
|
||||
assert_eq!(undo_len(&s), undo + 1);
|
||||
with_a_step_to_redo(&mut s);
|
||||
let before = history(&s);
|
||||
let pins = json!([[120, 350], [250, 350]]);
|
||||
assert_eq!(s.execute("object.puppetWarp", &json!({"id": b.0, "pins": pins, "moved": pins})).unwrap(), json!({"ids": [b.0]}));
|
||||
let live = matches!(s.doc().unwrap().doc.node(b).unwrap().kind, NodeKind::Path { live: Some(_), .. });
|
||||
assert_eq!((history(&s), live), (before, true), "a live shape with its pins left where they are");
|
||||
let held = s.execute("object.puppetWarp.pins", &json!({"id": a.0})).unwrap();
|
||||
assert_eq!(held["auto"], json!(false));
|
||||
let warped = path(&s, a);
|
||||
let still = json!({"id": a.0, "pins": [[120, 150], [250, 150]], "moved": [[120, 150], [250, 150]], "angles": [null, 0]});
|
||||
for p in [held, still] {
|
||||
assert_eq!(s.execute("object.puppetWarp", &p).unwrap(), json!({"ids": [a.0]}), "{p}");
|
||||
assert_eq!(history(&s), before, "{p}");
|
||||
assert_eq!(path(&s, a), warped, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- perspective ----------
|
||||
|
||||
fn grid(s: &Session) -> PerspectiveGrid {
|
||||
@@ -382,3 +499,103 @@ fn perspective_move_attaches_loose_objects_to_the_given_plane() {
|
||||
assert_eq!(grid(&s).attached_plane(a), Some(Plane::Ground));
|
||||
assert!(s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "none"})).is_err());
|
||||
}
|
||||
|
||||
/// With the grid defined, Define Perspective Grid with the values the grid has (Reproject or not),
|
||||
/// Attach to Active Plane with no object, Release with Perspective on art not in perspective, Move
|
||||
/// Plane to where the plane is (its objects moved with it or not), and Move Plane to Match Object for
|
||||
/// an object the active plane is at already record no undo step and reply as before.
|
||||
#[test]
|
||||
fn perspective_grid_commands_that_change_nothing_record_no_undo_step() {
|
||||
let mut s = session();
|
||||
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
|
||||
s.execute("perspective.grid.set", &json!({"cell": 40})).unwrap();
|
||||
s.execute("perspective.plane.set", &json!({"plane": "left"})).unwrap();
|
||||
let a = rect(&mut s, 300.0, 320.0, 40.0, 40.0);
|
||||
s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "left"})).unwrap();
|
||||
let loose = rect(&mut s, 600.0, 100.0, 20.0, 20.0);
|
||||
with_a_step_to_redo(&mut s);
|
||||
let before = history(&s);
|
||||
let definition = grid(&s).definition_json();
|
||||
let left = |ids: Vec<u64>| json!({"plane": "left", "offset": 0.0, "ids": ids});
|
||||
let calls = [
|
||||
("perspective.grid.set", json!({"cell": 40}), definition.clone()),
|
||||
("perspective.grid.set", json!({"cell": 40, "reproject": true}), definition),
|
||||
("perspective.attach", json!({"ids": []}), json!({"ids": [], "plane": "left"})),
|
||||
("perspective.release", json!({"ids": [loose.0]}), json!(null)),
|
||||
("perspective.plane.move", json!({"by": 0}), left(vec![])),
|
||||
("perspective.plane.move", json!({"offset": 0, "objects": "move"}), left(vec![a.0])),
|
||||
("perspective.plane.matchObject", json!({"id": a.0}), json!({"plane": "left", "offset": 0.0})),
|
||||
];
|
||||
for (c, p, reply) in calls {
|
||||
assert_eq!(s.execute(c, &p).unwrap(), reply, "{c} {p}");
|
||||
assert_eq!(history(&s), before, "{c} {p}");
|
||||
assert_eq!(s.journal.last().unwrap().0, c);
|
||||
}
|
||||
// Moving the plane by 0 with copies of its objects is one undo step.
|
||||
s.execute("perspective.plane.move", &json!({"by": 0, "objects": "copy"})).unwrap();
|
||||
assert_eq!(history(&s), (before.0 + 1, 0, true));
|
||||
}
|
||||
|
||||
/// For an object in perspective, Move in Perspective by 0 (along its plane or its normal),
|
||||
/// Transform in Perspective by the identity, Transform Again after each, and a Perspective Selection
|
||||
/// drag that ends where it started record no undo step. So do a Move by 0 and a 1 pt Nudge that
|
||||
/// Snap to Grid takes back to the gridlines the object's edges are on, and Transform Again after
|
||||
/// each. With Copy, the identity copies the object in one undo step.
|
||||
#[test]
|
||||
fn perspective_moves_that_move_nothing_record_no_undo_step() {
|
||||
let mut s = session();
|
||||
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
|
||||
s.execute("perspective.grid.snap", &json!({"on": false})).unwrap();
|
||||
let a = rect(&mut s, 450.0, 380.0, 60.0, 60.0);
|
||||
s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "right"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
with_a_step_to_redo(&mut s);
|
||||
let before = history(&s);
|
||||
let shape = path(&s, a);
|
||||
let at = json!([480, 410]);
|
||||
let calls = [
|
||||
("perspective.move", json!({"from": at, "to": at})),
|
||||
("object.transformAgain", json!({})),
|
||||
("perspective.move", json!({"from": at, "to": at, "perpendicular": true})),
|
||||
("object.transformAgain", json!({})),
|
||||
("perspective.transform", json!({"matrix": [1, 0, 0, 1, 0, 0]})),
|
||||
("object.transformAgain", json!({})),
|
||||
];
|
||||
for (c, p) in calls {
|
||||
assert_eq!(s.execute(c, &p).unwrap(), json!({"ids": [a.0]}), "{c} {p}");
|
||||
assert_eq!((history(&s), path(&s, a)), (before, shape.clone()), "{c} {p}");
|
||||
assert_eq!(s.journal.last().unwrap().0, c);
|
||||
}
|
||||
// Grabbing the object makes its plane the active one (perspective.plane.set), which marks the
|
||||
// document modified without an undo step.
|
||||
gesture(&mut s, "perspectiveSelection", &[(480.0, 410.0), (500.0, 420.0), (480.0, 410.0), (480.0, 410.0)], Mods::default());
|
||||
assert_eq!(s.journal.last().unwrap().0, "perspective.move");
|
||||
let (undo, redo, _) = history(&s);
|
||||
assert_eq!(((undo, redo), path(&s, a)), ((before.0, before.1), shape.clone()), "a drag back to its start");
|
||||
// Snap to Grid takes a move that ends 0.3 pt past a gridline crossing onto the crossing. Then it
|
||||
// takes a move by 0 and a 1 pt nudge back there, and Transform Again after each moves nothing.
|
||||
s.execute("perspective.grid.snap", &json!({"on": true})).unwrap();
|
||||
let g = grid(&s);
|
||||
let (_, _, b) = g.plane_bounds(&s.doc().unwrap().doc, &[a]).unwrap();
|
||||
let past = |v: f64| (v / g.cell).ceil() * g.cell + 0.3 - v;
|
||||
let (c, dv) = (b.center(), vectorcraft_geom::Vec2::new(past(b.x0), past(b.y0)));
|
||||
let (from, to) = (g.to_page(Plane::Right, c).unwrap(), g.to_page(Plane::Right, c + dv).unwrap());
|
||||
s.execute("perspective.move", &json!({"from": [from.x, from.y], "to": [to.x, to.y]})).unwrap();
|
||||
with_a_step_to_redo(&mut s);
|
||||
let before = history(&s);
|
||||
let on_grid = path(&s, a);
|
||||
let calls = [
|
||||
("perspective.move", json!({"from": [to.x, to.y], "to": [to.x, to.y]})),
|
||||
("object.transformAgain", json!({})),
|
||||
("perspective.nudge", json!({"dx": 1, "dy": 0})),
|
||||
("object.transformAgain", json!({})),
|
||||
];
|
||||
for (c, p) in calls {
|
||||
assert_eq!(s.execute(c, &p).unwrap(), json!({"ids": [a.0]}), "{c} {p}");
|
||||
assert_eq!((history(&s), path(&s, a)), (before, on_grid.clone()), "{c} {p}");
|
||||
}
|
||||
// The identity with Copy copies the object in one undo step.
|
||||
let r = s.execute("perspective.transform", &json!({"matrix": [1, 0, 0, 1, 0, 0], "copy": true})).unwrap();
|
||||
assert_ne!(r, json!({"ids": [a.0]}));
|
||||
assert_eq!(history(&s), (before.0 + 1, 0, true));
|
||||
}
|
||||
@@ -153,6 +153,73 @@ fn lock_and_hide_other_layers() {
|
||||
assert!(!s.doc().unwrap().doc.node(l2).unwrap().visible);
|
||||
}
|
||||
|
||||
/// How many steps there are to undo and to redo, and whether the document has unsaved changes.
|
||||
fn history(s: &Session) -> (usize, usize, bool) {
|
||||
let st = s.doc().unwrap();
|
||||
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
|
||||
}
|
||||
|
||||
/// Select `ids` with a move of them left to redo, and mark the document saved.
|
||||
fn select_with_a_step_to_redo(s: &mut Session, ids: &[NodeId]) {
|
||||
sel(s, ids);
|
||||
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
}
|
||||
|
||||
/// Lock › All Artwork Above and Hide › All Artwork Above with no object above the selection, or with
|
||||
/// every one locked (Lock) or hidden (Hide) already, and Lock › Other Layers and Hide › Other Layers
|
||||
/// with no other layer, or with every one locked or hidden already, record no undo step: the step to
|
||||
/// redo stays and the document stays unmodified. All Artwork Above still deselects a selected object
|
||||
/// that is hidden.
|
||||
#[test]
|
||||
fn lock_and_hide_with_nothing_to_lock_or_hide_record_no_undo_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let over = rect(&mut s, 50.0, 50.0, 100.0, 100.0);
|
||||
// Nothing is above `over`, and the document has one layer.
|
||||
select_with_a_step_to_redo(&mut s, &[over]);
|
||||
let before = history(&s);
|
||||
assert_eq!((before.1, before.2), (1, false), "a step to redo, and nothing unsaved");
|
||||
for c in ["object.lock.above", "object.hide.above", "object.lock.otherLayers", "object.hide.otherLayers"] {
|
||||
assert_eq!(s.execute(c, &json!({})).unwrap(), json!({"count": 0}), "{c}");
|
||||
assert_eq!(history(&s), before, "{c}");
|
||||
assert_eq!(selected(&s), [over], "{c}");
|
||||
}
|
||||
// Lock › All Artwork Above locks `over` above `a` in one undo step, and then it has nothing left
|
||||
// to lock. Hide › All Artwork Above then hides `over`, and then it has nothing left to hide.
|
||||
for c in ["object.lock.above", "object.hide.above"] {
|
||||
select_with_a_step_to_redo(&mut s, &[a]);
|
||||
let undo = history(&s).0;
|
||||
assert_eq!(s.execute(c, &json!({})).unwrap(), json!({"count": 1}), "{c}");
|
||||
assert_eq!(history(&s), (undo + 1, 0, true), "{c}");
|
||||
select_with_a_step_to_redo(&mut s, &[a]);
|
||||
let before = history(&s);
|
||||
assert_eq!(s.execute(c, &json!({})).unwrap(), json!({"count": 1}), "{c} again");
|
||||
assert_eq!(history(&s), before, "{c} again");
|
||||
}
|
||||
// A hidden object selected beside `a` is deselected, still without an undo step.
|
||||
let hidden = rect(&mut s, 400.0, 400.0, 20.0, 20.0);
|
||||
s.execute("layer.setProps", &json!({"id": hidden.0, "visible": false})).unwrap();
|
||||
select_with_a_step_to_redo(&mut s, &[hidden, a]);
|
||||
let before = history(&s);
|
||||
assert_eq!(s.execute("object.hide.above", &json!({})).unwrap(), json!({"count": 1}));
|
||||
assert_eq!((history(&s), selected(&s)), (before, vec![a]));
|
||||
// Other Layers locks, then hides, a second layer in one undo step each, and then it has nothing
|
||||
// left to lock or hide.
|
||||
s.execute("layer.new", &json!({})).unwrap();
|
||||
for c in ["object.lock.otherLayers", "object.hide.otherLayers"] {
|
||||
select_with_a_step_to_redo(&mut s, &[a]);
|
||||
let undo = history(&s).0;
|
||||
assert_eq!(s.execute(c, &json!({})).unwrap(), json!({"count": 1}), "{c}");
|
||||
assert_eq!(history(&s), (undo + 1, 0, true), "{c}");
|
||||
select_with_a_step_to_redo(&mut s, &[a]);
|
||||
let before = history(&s);
|
||||
assert_eq!(s.execute(c, &json!({})).unwrap(), json!({"count": 1}), "{c} again");
|
||||
assert_eq!(history(&s), before, "{c} again");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Transform Each ----------
|
||||
|
||||
#[test]
|
||||
@@ -195,6 +262,37 @@ fn transform_each_random_is_deterministic_with_seed() {
|
||||
assert!(x.x0.abs() <= 50.0 && x.width() == 100.0);
|
||||
}
|
||||
|
||||
/// Transform Each with its default values, or with any values that move nothing (both scales 100%,
|
||||
/// no move, rotation or reflection, Random or not), records no undo step and replies with the
|
||||
/// objects. With only a layer selected there is no object to transform: with Copy or without, it
|
||||
/// records no undo step and keeps the selection. With Copy and objects selected, the default values
|
||||
/// copy them in one undo step.
|
||||
#[test]
|
||||
fn transform_each_that_changes_nothing_records_no_undo_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let b = rect(&mut s, 200.0, 0.0, 100.0, 100.0);
|
||||
select_with_a_step_to_redo(&mut s, &[a, b]);
|
||||
let before = history(&s);
|
||||
let still = json!({"scaleH": 100, "scaleV": 100, "moveH": 0, "moveV": 0, "rotate": 0, "reflectX": false, "reflectY": false, "random": true});
|
||||
for p in [json!({}), still, json!({"reference": 0, "strokes": true, "corners": true, "patterns": true})] {
|
||||
assert_eq!(s.execute("object.transformEach", &p).unwrap(), json!({"ids": [a.0, b.0]}), "{p}");
|
||||
assert_eq!(history(&s), before, "{p}");
|
||||
assert_eq!(s.journal.last().unwrap().0, "object.transformEach");
|
||||
}
|
||||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||||
sel(&mut s, &[layer]);
|
||||
for p in [json!({"moveH": 10}), json!({"moveH": 10, "copy": true})] {
|
||||
assert_eq!(s.execute("object.transformEach", &p).unwrap(), json!({"ids": []}), "{p}");
|
||||
assert_eq!(history(&s), before, "{p}");
|
||||
assert_eq!(selected(&s), [layer], "{p}");
|
||||
}
|
||||
sel(&mut s, &[a, b]);
|
||||
let r = s.execute("object.transformEach", &json!({"copy": true})).unwrap();
|
||||
assert_eq!(r["ids"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(history(&s), (before.0 + 1, 0, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_bounding_box_is_a_noop() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -283,3 +283,32 @@ fn rearrange_aliases_and_rejects_bad_values() {
|
||||
}
|
||||
assert_eq!(boards(&s), before, "nothing moved");
|
||||
}
|
||||
|
||||
/// Rearrange All Artboards with the artboards already where it puts them, or with no artboard,
|
||||
/// records no undo step: the step to redo stays, the document stays unmodified, and the reply is the
|
||||
/// same.
|
||||
#[test]
|
||||
fn rearrange_that_moves_no_artboard_records_no_undo_step() {
|
||||
let mut s = new_doc(json!({"width": 100, "height": 50, "artboards": 3}));
|
||||
let p = json!({"columns": 2, "spacing": 5});
|
||||
let reply = s.execute("artboard.rearrange", &p).unwrap();
|
||||
let placed = boards(&s);
|
||||
// A rectangle drawn and undone leaves a step to redo, and the document is marked saved.
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
let history = |s: &Session| {
|
||||
let st = s.doc().unwrap();
|
||||
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
|
||||
};
|
||||
let before = history(&s);
|
||||
assert_eq!((before.1, before.2), (1, false));
|
||||
assert_eq!(s.execute("artboard.rearrange", &p).unwrap(), reply);
|
||||
assert_eq!((history(&s), boards(&s)), (before, placed));
|
||||
// A file can hold no artboard.
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).artboards.clear();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
let before = history(&s);
|
||||
assert_eq!(s.execute("artboard.rearrange", &p).unwrap(), json!({"artboards": 0, "rows": 1, "columns": 1}));
|
||||
assert_eq!(history(&s), before);
|
||||
}
|
||||
@@ -443,6 +443,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names.
|
||||
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
|
||||
Object › Lock › All Artwork Above and Object › Hide › All Artwork Above deselect the selected objects they lock or hide, the key object included.
|
||||
All Artwork Above and Other Layers (Object › Lock and Object › Hide), Transform Each, Rearrange All Artboards, the width point and width profile commands, Liquify, Puppet Warp, Define Perspective Grid, Attach to Active Plane, Release with Perspective, moves and transforms in perspective, Move Plane and Move Plane to Match Object record no undo step when they change nothing.
|
||||
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). When a document opens with fonts that aren't available, fonts used only in symbols included, a Missing Fonts dialog lists them after the linked-file questions, with Find Fonts (Type › Find Font on them) and Close. Its Find in Folder… (in Find Font too) searches a folder on several threads, reads only what the font scan reads of font files and skips app packages, the Program Files and ProgramData folders, the system's folders and the app data folders in a home folder; Add Fonts copies the font files the user chooses, suitcase fonts with their resource fork, into VectorCraft's own Fonts folder, which the desktop app and `vectorcraft-cli` read (`text.missingFonts`, `text.findFontFiles`, `text.addFontFiles`).
|
||||
- **Files (M4):**
|
||||
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465).
|
||||
|
||||
Reference in new issue
Block a user