Transform: Align to Pixel Grid keeps an object's horizontal and vertical edges on the pixel grid

Squashed from #6169 onto main.
This commit is contained in:
dotCooCoo
2026-11-09 00:29:28 +07:00
committed by bflatastic
parent b568977af4
commit 581ced3213
58 changed files with 1565 additions and 15 deletions
+4
View File
@@ -69,6 +69,9 @@ pub struct NodeExtras {
/// As an envelope with margins in Edit Contents, the margins its strokes gave when Edit Contents
/// began ([`crate::live::EnvelopeOptions::bounds_margin`]).
pub envelope_edit_margin: Option<[f32; 4]>,
/// Transform panel › Align to Pixel Grid: the path's or compound path's horizontal and vertical
/// segments stay on the pixel grid when it is transformed.
pub pixel_aligned: bool,
}
/// No extras: what [`Node::extras`] gives for a node without any.
@@ -95,6 +98,7 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras {
shape_mode: crate::ShapeMode::Add,
symbol_overrides: Vec::new(),
envelope_edit_margin: None,
pixel_aligned: false,
};
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
+3
View File
@@ -22,6 +22,7 @@ fn extras_are_written_flat_in_order() {
e.compound_shape = true;
e.shape_mode = crate::ShapeMode::Exclude;
e.envelope_edit_margin = Some([0.5, 0.5, 0.25, 0.25]);
e.pixel_aligned = true;
}
/// The top-level keys of a JSON object, in the order written.
struct Keys(Vec<String>);
@@ -67,9 +68,11 @@ fn extras_are_written_flat_in_order() {
"compound_shape",
"shape_mode",
"envelope_edit_margin",
"pixel_aligned",
];
assert_eq!(keys, order);
assert_eq!(v["shape_mode"], "exclude");
assert_eq!(v["pixel_aligned"], true);
assert_eq!(serde_json::from_value::<Node>(v).unwrap(), n);
let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art());
let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap();
+5
View File
@@ -54,6 +54,7 @@ impl Serialize for Node {
is_default(&e.shape_mode),
e.symbol_overrides.is_empty(),
e.envelope_edit_margin.is_none(),
is_default(&e.pixel_aligned),
];
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
let mut skip = skips.into_iter();
@@ -96,6 +97,7 @@ impl Serialize for Node {
field!("shape_mode", &e.shape_mode);
field!("symbol_overrides", &e.symbol_overrides);
field!("envelope_edit_margin", &e.envelope_edit_margin);
field!("pixel_aligned", &e.pixel_aligned);
st.end()
}
}
@@ -236,6 +238,8 @@ pub(super) struct NodeWire {
symbol_overrides: Vec<crate::SymbolOverride>,
#[serde(default)]
envelope_edit_margin: Option<[f32; 4]>,
#[serde(default)]
pixel_aligned: bool,
}
impl From<NodeWire> for Node {
@@ -263,6 +267,7 @@ impl From<NodeWire> for Node {
shape_mode: w.shape_mode,
symbol_overrides: w.symbol_overrides,
envelope_edit_margin: w.envelope_edit_margin,
pixel_aligned: w.pixel_aligned,
};
Node {
id: w.id,
+1
View File
@@ -674,6 +674,7 @@ pub(crate) const MAX_IMPORTED_PRESETS: usize = 1000;
pub(crate) use edit::{settle_interaction, typing_in_progress};
pub(crate) use object::bounds_rewrite;
pub(crate) use object::{keep_aligned_in, refresh_pixel_aligned};
pub(crate) use vectorcraft_doc::range::parse_range;
/// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see
+24 -1
View File
@@ -598,6 +598,9 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
n.appearance.items.push(item);
added.push((*id, fill));
}
if !fill {
super::object::snap_if_aligned(n);
}
}
}
super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
@@ -620,6 +623,7 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
clear(n);
super::object::snap_if_aligned(n);
}
}
let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids));
@@ -727,9 +731,11 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
}
to.appearance = ap;
(to.opacity, to.blend, to.isolate, to.knockout, to.knockout_shape) = (src.opacity, src.blend, src.isolate, src.knockout, src.knockout_shape);
super::object::snap_if_aligned(to);
if !copy && let Some(n) = d.node_mut(source) {
clear(n);
(n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false);
super::object::snap_if_aligned(n);
}
let changed = if copy { vec![target] } else { vec![source, target] };
let kept = super::graph::capture_series_opacity(d, &both_kinds(&changed));
@@ -788,6 +794,7 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
st.blend = BlendMode::Normal;
n.appearance.items.push(AppearanceItem::Stroke(st));
}
super::object::snap_if_aligned(n);
}
}
let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids));
@@ -847,6 +854,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
*vis = v;
}
super::object::snap_if_aligned(n);
let items = &n.appearance.items;
if let Some(fill) =
items.get(idx).map(AppearanceItem::is_fill).filter(|f| items.iter().rposition(|it| it.is_fill() == *f && it.visible()) == Some(idx))
@@ -907,6 +915,7 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
for &i in idx.iter().rev() {
n.appearance.remove_item(i);
}
super::object::snap_if_aligned(n);
}
}
let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
@@ -1011,7 +1020,19 @@ fn show_all_hidden(s: &mut Session, p: &Value) -> Result<Value> {
let roots = require_targets(s, p, "appearance.showAllHidden")?;
let before = s.doc()?.doc.clone();
let ids = s.edit("Show All Hidden Attributes", |d, _| {
let shown: Vec<NodeId> = roots.iter().copied().filter(|id| d.node_mut(*id).is_some_and(|n| n.appearance.show_all())).collect();
let shown: Vec<NodeId> = roots
.iter()
.copied()
.filter(|id| {
d.node_mut(*id).is_some_and(|n| {
let shown = n.appearance.show_all();
if shown {
super::object::snap_if_aligned(n);
}
shown
})
})
.collect();
if shown.is_empty() {
return Err(EngineError::Other("Show All Hidden Attributes: nothing is hidden".into()));
}
@@ -1408,6 +1429,7 @@ impl Picked {
let placed = self.placed(n.geometric_bounds());
n.appearance.items.extend(placed.items.into_iter().filter(|it| if it.is_fill() { ap.fill.color } else { ap.stroke.color }));
n.appearance.effects.extend(placed.effects);
super::object::snap_if_aligned(n);
return;
}
if let NodeKind::Text(t) = &mut n.kind {
@@ -1431,6 +1453,7 @@ impl Picked {
} else {
focal_attrs(&mut n.appearance, &placed, ap);
}
super::object::snap_if_aligned(n);
}
}
+18 -2
View File
@@ -1299,10 +1299,11 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
for (k, n) in clip.nodes.iter().enumerate() {
let mut c = d.reid(n);
imported.apply(&mut c);
let sc = vectorcraft_doc::Scaling { patterns: mode == PasteMode::Drag, ..Default::default() };
if xf != Affine::IDENTITY {
// A cross-document drag carries the graphic's appearance with it,
// including the pattern phase, independently of transform-tool settings.
c.transform(xf, vectorcraft_doc::Scaling { patterns: mode == PasteMode::Drag, ..Default::default() });
c.transform(xf, sc);
}
let layer = match clip.source_layers.get(k) {
Some(Some(name)) if remember => Some(*layers.entry(name).or_insert_with(|| layer_named(d, name))),
@@ -1338,6 +1339,9 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
if let NodeKind::Path { live, .. } = &mut child_node.kind {
*live = None;
}
if child_node.extras().pixel_aligned {
child_node.extras_mut().pixel_aligned = false;
}
let insert_idx = if idx == usize::MAX { usize::MAX } else { idx.saturating_add(offset) };
new_ids.push(d.insert(par, insert_idx, child_node)?);
}
@@ -1350,6 +1354,10 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
if let NodeKind::Path { live, .. } = &mut c.kind {
*live = None;
}
// A path inside a compound path snaps with the compound path, whose property it takes.
if c.extras().pixel_aligned {
c.extras_mut().pixel_aligned = false;
}
new_ids.push(d.insert(par, idx, c)?);
}
_ => {
@@ -1358,14 +1366,19 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
let offset = *placed.entry(outer_par).or_default();
*placed.entry(outer_par).or_default() += 1;
let insert_idx = if beside_idx == usize::MAX { usize::MAX } else { beside_idx.saturating_add(offset) };
super::object::keep_pixel_aligned(&mut c, sc);
new_ids.push(d.insert(outer_par, insert_idx, c)?);
}
}
} else {
super::object::keep_pixel_aligned(&mut c, sc);
new_ids.push(d.insert(par, idx, c)?);
}
}
}
for id in &new_ids {
super::object::keep_compound_aligned(d, *id, vectorcraft_doc::Scaling::default());
}
sel.set(new_ids.iter().copied());
// Copied ruler guides (#3226): on the layer new art goes to, moved as the objects are (a
// paste of guides alone keeps them where they were, but onto another artboard or every
@@ -1437,7 +1450,10 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
/// Duplicate `roots` directly above each original, transform the copies, select them
/// ([`copy_above`]).
pub(crate) fn duplicate_in(d: &mut Document, sel: &mut vectorcraft_doc::Selection, roots: &[NodeId], xf: Affine) -> Result<Vec<NodeId>> {
let out = copy_above(d, roots, |_, _, c| c.transform(xf, true))?;
let out = copy_above(d, roots, |_, _, c| {
c.transform(xf, true);
super::object::keep_pixel_aligned(c, true.into());
})?;
sel.set(out.iter().copied());
Ok(out)
}
+2
View File
@@ -768,10 +768,12 @@ pub(crate) fn propagate(before: &Document, d: &mut Document, ge: &GlobalEdit, se
let mut placed = model.clone();
placed.transform(*xf * back, Scaling::default());
give(was, &model, placed, &mut n, d);
super::object::keep_pixel_aligned(&mut n, Scaling::default());
if let Some(slot) = d.node_mut(*id) {
*slot = n;
edited.push(*id);
}
super::object::keep_compound_aligned(d, *id, Scaling::default());
}
edited
}
+1
View File
@@ -1125,6 +1125,7 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
for id in &e.art {
if let Some(n) = d.node_mut(*id) {
n.transform(xf, sc);
super::object::keep_pixel_aligned(n, sc);
}
}
} else if !e.resized {
+4
View File
@@ -620,6 +620,9 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result<Value> {
if let Some(n) = d.node_mut(*id) {
super::object::transform_node(n, m, sc, objects);
}
if objects {
super::object::keep_compound_aligned(d, *id, sc);
}
}
Ok(targets)
})?;
@@ -1363,6 +1366,7 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
for (id, dl) in &art {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(*dl), sc);
super::object::keep_pixel_aligned(n, sc);
}
}
let mut moved = vec![];
+2
View File
@@ -25,6 +25,7 @@ mod arrange;
mod bounds;
mod commands;
mod compound;
mod pixelgrid;
mod props;
mod shape;
mod spans;
@@ -35,5 +36,6 @@ pub(crate) use arrange::group_nodes;
pub(crate) use bounds::{bounds_rewrite, object_bounds_transform};
pub use commands::specs;
pub(super) use compound::{as_clipping_path, make_clipping_path, release_clip};
pub(crate) use pixelgrid::{keep_aligned_in, keep_compound_aligned, keep_pixel_aligned, refresh as refresh_pixel_aligned, resnap, snap_if_aligned};
pub(super) use shape::recut;
pub(crate) use transform::{apply_transform, preview_copies, scaling, transform_node, transform_patterns, transforms_objects};
+15
View File
@@ -112,7 +112,9 @@ fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: &
for (id, dv) in moves {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(*dv), sc);
super::pixelgrid::keep_pixel_aligned(n, sc);
}
super::pixelgrid::keep_compound_aligned(d, *id, sc);
}
for &(i, pos) in &guides {
if let Some(g) = d.guides.get_mut(i) {
@@ -330,6 +332,7 @@ fn align_anchors(s: &mut Session, p: &Value, ids: &[NodeId], (h, v): (Option<&st
a.translate(target(a.p) - a.p);
}
}
snap_anchor_path(d, *id);
}
Ok(())
})?;
@@ -434,11 +437,23 @@ fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option<f64
a.translate(if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) });
}
}
for (id, _) in &targets {
snap_anchor_path(d, *id);
}
Ok(())
})?;
ok()
}
/// After anchors of path `id` moved: snap the path, or the compound path that holds it, to the
/// pixel grid again when it is pixel-aligned.
fn snap_anchor_path(d: &mut Document, id: NodeId) {
let top = crate::cmd::path::compound_or_self(d, id);
if let Some(n) = d.node_mut(top) {
super::pixelgrid::snap_if_aligned(n);
}
}
pub(super) fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &["patterns"], "object.distribute")?;
check_numbers(p, &["artboard"], "object.distribute")?;
+10
View File
@@ -2,6 +2,7 @@ use super::align::{align, can_distribute, distribute, distribute_spacing};
use super::arrange::{Arrange, arrange, exit_isolation, group, hide, isolate, lock, send_to_current_layer, show_all, ungroup, unlock_all};
use super::bounds::set_bounds;
use super::compound::{clip_edit, clip_make, clip_release, compound_make, compound_release};
use super::pixelgrid::set_pixel_aligned;
use super::props::set_props;
use super::shape::{expand_shape, set_live_shape};
use super::transform::{move_cmd, nudge, reflect, rotate, scale, shear, transform, transform_again};
@@ -300,5 +301,14 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
set_live_shape
),
cmd!(
"object.setPixelAligned",
"Align to Pixel Grid",
[],
None,
"{ids?|id?, on?: bool (default true)} → {ids (the objects whose property changed)} (Transform panel › Align to Pixel Grid: on, each object's horizontal and vertical straight segments move to whole points, or half points when a visible, painted centered stroke has an odd weight, its stroke weights become whole points (at least 1), and every later move, scale, rotation, reflection, Transform Each, Align move, Transform panel change, move of direct-selected anchors or of one anchor (path.moveAnchor), path.setAnchors, object.setLiveShape, move of a compound path's path on its own, artboard move or resize that carries the art (artboard.move, artboard.setProps) or copies it (artboard.move with copy, Duplicate Artboards), every copy that edit.duplicate or a paste moves and object.slice.move of an object slice snaps them again, as does every stroke.set, stroke.weightUp or stroke.weightDown edit (which also rounds the new weight) and every appearance.setItem, appearance.addStroke, appearance.removeItem, appearance.clear, appearance.reduceToBasic, appearance.showAllHidden, appearance.transfer, appearance.copyFrom, paint.setStroke or graphicStyle.apply edit, and every other command that changes the object; diagonal segments and curves keep their shape apart from the anchors they share with those segments, and coordinates that differ stay at least 1 point apart; a live rectangle, polygon, line or pie moves and scales as a whole to the snapped bounds (a full ellipse, all curves, stays as it is), and a path with Live Corners snaps the outline its corners are cut from; a compound path snaps its paths with its own stroke, Make Compound Path from an aligned path or compound path gives an aligned compound path whose paths drop the property, and Release Compound Path gives each path the compound path's property; off, the object keeps its geometry and stops snapping. Only paths and compound paths can be aligned: type, images, groups, guides, paths inside a compound path and other objects are an error and nothing changes. One undo step; nothing changes and no undo step is made when every object already has the value. Saved in the native file)",
has_selection,
set_pixel_aligned
),
]
}
+13
View File
@@ -13,11 +13,15 @@ pub(super) fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
d.node(backmost).ok_or(EngineError::NoNode(backmost))?;
let mut units: Vec<Vec<Arc<Node>>> = vec![];
let mut appearance = None;
// The compound path is pixel-aligned when any of its paths was, or any compound path whose
// paths it takes; its paths snap with its stroke, so they do not keep the property themselves.
let mut aligned = false;
for id in &ids {
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
if !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Group { .. }) {
return Err(EngineError::Other("compound paths can only contain paths".into()));
}
n.walk(&mut |c| aligned |= c.extras().pixel_aligned);
compound_members(n, &mut appearance, &mut units);
}
let cid = d.alloc_id();
@@ -40,6 +44,7 @@ pub(super) fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
for mut p in unit {
let pn = Arc::make_mut(&mut p);
pn.appearance = Appearance::default().into();
pn.extras_mut().pixel_aligned = false;
if let NodeKind::Path { path, live, .. } = &mut pn.kind {
*live = None;
if flip {
@@ -49,6 +54,10 @@ pub(super) fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
ch.push(p);
}
}
if aligned {
c.extras_mut().pixel_aligned = true;
super::pixelgrid::snap_if_aligned(&mut c);
}
d.wrap(&ids, |_| c)?;
sel.set([cid]);
Ok(cid)
@@ -99,6 +108,10 @@ pub(super) fn compound_release(s: &mut Session, _: &Value) -> Result<Value> {
for c in children {
let mut c = (**c).clone();
c.appearance = n.appearance.clone();
// Each path keeps the compound path's pixel-aligned property.
if n.extras().pixel_aligned {
c.extras_mut().pixel_aligned = true;
}
out.push(c.id);
parts.push(c);
}
+407
View File
@@ -0,0 +1,407 @@
//! Transform panel › Align to Pixel Grid: an object's pixel-aligned property, and the snapping that
//! keeps the horizontal and vertical segments of a pixel-aligned object on the pixel grid
//! (<https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/pixel-preview-mode-for-web-graphics.html>).
use vectorcraft_doc::{AppearanceItem, LiveShape, StrokeAlign};
use super::{
Affine, Arc, Document, EngineError, Node, NodeId, NodeKind, Paint, PathData, Rect, Result, Scaling, Session, Value, Vec2, bad, json,
selected_roots,
};
use crate::cmd::{given_objects, switch_or};
const C: &str = "object.setPixelAligned";
/// How far apart two coordinates may be and still count as one, in points: segments this close to
/// horizontal or vertical count as horizontal or vertical.
const EPS: f64 = 1e-6;
/// Whether `n` can be aligned to the pixel grid: a path that is not a guide, or a compound path.
/// Type, images, groups and other objects cannot.
pub(crate) fn can_align(n: &Node) -> bool {
matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. })
}
/// `object.setPixelAligned`: turn the pixel-aligned property of the objects given (or the selected
/// objects) on or off. Turning it on snaps them to the pixel grid at once. Every object must be
/// one [`can_align`] accepts, else nothing changes. When every object already has the value, the
/// document and its undo history stay as they are.
pub(super) fn set_pixel_aligned(s: &mut Session, p: &Value) -> Result<Value> {
let on = switch_or(p, "on", true, C)?;
let ids = match given_objects(s, p, C)? {
Some(ids) => ids,
None => selected_roots(s)?,
};
let d = &s.doc()?.doc;
let mut changed: Vec<NodeId> = vec![];
for id in &ids {
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
// A path inside a compound path snaps with the compound path, whose property it takes.
let in_compound = d.parent_of(*id).and_then(|p| d.node(p)).is_some_and(|p| matches!(p.kind, NodeKind::Compound { .. }));
if !can_align(n) || in_compound {
return Err(bad(C, "only paths and compound paths can be aligned to the pixel grid"));
}
if n.extras().pixel_aligned != on && !changed.contains(id) {
changed.push(*id);
}
}
if changed.is_empty() {
return Ok(json!({ "ids": [] }));
}
s.edit("Align to Pixel Grid", |d, _| {
for id in &changed {
let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?;
n.extras_mut().pixel_aligned = on;
if on {
snap_object(n, Scaling::default());
}
}
Ok(())
})?;
Ok(json!({ "ids": changed.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
/// After a transform of `n` with `sc`: snap `n` and every object in it that is pixel-aligned to the
/// pixel grid, the art of linked opacity masks included. Objects that are not pixel-aligned stay as
/// they are.
pub(crate) fn keep_pixel_aligned(n: &mut Node, sc: Scaling) {
let mut found = vec![];
aligned_within(n, &mut vec![], &mut found);
for at in found {
if let Some(a) = node_at(n, &at) {
snap_object(a, sc);
}
}
}
/// One step from an object down to an object it holds: its child at an index, or the art of its
/// linked opacity mask.
#[derive(Clone, Copy)]
enum Step {
Child(usize),
Mask,
}
/// Add to `out` the steps from the object [`keep_pixel_aligned`] started at to `n` (`at`) and to
/// every pixel-aligned object in `n`, in the art of linked opacity masks included, `n` first. One
/// walk visits each object once, so nothing is copied that does not snap.
fn aligned_within(n: &Node, at: &mut Vec<Step>, out: &mut Vec<Vec<Step>>) {
if n.extras().pixel_aligned {
out.push(at.clone());
}
// The paths of a pixel-aligned compound path snap with it, but their opacity masks still need a visit.
for (i, c) in n.children().map_or(&[][..], Vec::as_slice).iter().enumerate() {
at.push(Step::Child(i));
aligned_within(c, at, out);
at.pop();
}
if let Some(m) = n.extras().mask.as_ref()
&& m.linked
{
at.push(Step::Mask);
aligned_within(&m.art, at, out);
at.pop();
}
}
/// The object that `steps` lead to from `n`, made unique for editing.
fn node_at<'a>(n: &'a mut Node, steps: &[Step]) -> Option<&'a mut Node> {
let mut cur = n;
for s in steps {
cur = match s {
Step::Child(i) => Arc::make_mut(cur.children_mut()?.get_mut(*i)?),
Step::Mask => Arc::make_mut(&mut cur.extras_if_any()?.mask.as_mut()?.art),
};
}
Some(cur)
}
/// After a transform of object `id` with `sc`: when `id` is a path of a pixel-aligned compound path
/// (moved on its own, as with Group Selection), snap that compound path to the pixel grid again.
/// The compound path holds the property, so [`keep_pixel_aligned`] on the path alone does nothing.
pub(crate) fn keep_compound_aligned(d: &mut Document, id: NodeId, sc: Scaling) {
let Some(parent) = d.parent_of(id) else { return };
if let Some(n) = d.node_mut(parent)
&& matches!(n.kind, NodeKind::Compound { .. })
&& n.extras().pixel_aligned
{
snap_object(n, sc);
}
}
/// After any edit ([`Session::edit`]): snap each pixel-aligned object that the edit changed or added
/// (its node in `doc` is not the one in `before`), with the pixel-aligned art of its linked opacity
/// mask, to the pixel grid. Objects the edit did not touch are not visited.
pub(crate) fn refresh(before: &Document, doc: &mut Document) {
let mut ids = vec![];
changed_aligned(&before.layers, &doc.layers, &mut ids);
for id in ids {
if let Some(n) = doc.node_mut(id) {
keep_pixel_aligned(n, Scaling::default());
}
}
}
/// After a pass that moved what objects `ids` hold (Objects on Path placing its members): snap the
/// pixel-aligned objects in them to the pixel grid again.
pub(crate) fn keep_aligned_in(doc: &mut Document, ids: &[NodeId]) {
for id in ids {
if doc.node(*id).is_some_and(holds_aligned)
&& let Some(n) = doc.node_mut(*id)
{
keep_pixel_aligned(n, Scaling::default());
}
}
}
/// The objects of `after` that differ from their counterparts in `before` (or are new) and are
/// pixel-aligned or hold pixel-aligned art in a linked opacity mask. Inside such an object the
/// walk stops: [`keep_pixel_aligned`] visits what it holds.
fn changed_aligned(before: &[Arc<Node>], after: &[Arc<Node>], out: &mut Vec<NodeId>) {
let mut by_id: Option<std::collections::HashMap<NodeId, &Arc<Node>>> = None;
for (i, a) in after.iter().enumerate() {
let b = match before.get(i) {
Some(b) if b.id == a.id => Some(b),
_ => by_id.get_or_insert_with(|| before.iter().map(|b| (b.id, b)).collect()).get(&a.id).copied(),
};
if b.is_some_and(|b| Arc::ptr_eq(b, a)) {
continue;
}
if a.extras().pixel_aligned || a.extras().mask.as_ref().is_some_and(|m| m.linked && holds_aligned(&m.art)) {
out.push(a.id);
} else if let Some(children) = a.children() {
changed_aligned(b.and_then(|b| b.children()).map_or(&[], Vec::as_slice), children, out);
}
}
}
/// After an edit that replaces object `id`'s geometry: snap it, or the pixel-aligned compound path
/// that holds it, to the pixel grid again when it is pixel-aligned.
pub(crate) fn resnap(d: &mut Document, id: NodeId) {
if let Some(n) = d.node_mut(id) {
snap_if_aligned(n);
}
keep_compound_aligned(d, id, Scaling::default());
}
/// After an edit of `n`'s strokes: when `n` is pixel-aligned, round its stroke weights and snap it to
/// the pixel grid again, since a new weight or alignment can move where the stroke's edges fall.
pub(crate) fn snap_if_aligned(n: &mut Node) {
if n.extras().pixel_aligned {
snap_object(n, Scaling::default());
}
}
/// Whether `n` or an object in it, or in the art of a linked opacity mask in it, is pixel-aligned.
/// It goes only where a transform of `n` goes: not into unlinked masks.
fn holds_aligned(n: &Node) -> bool {
n.extras().pixel_aligned
|| n.extras().mask.as_ref().is_some_and(|m| m.linked && holds_aligned(&m.art))
|| n.children().is_some_and(|ch| ch.iter().any(|c| holds_aligned(c)))
}
/// Snap pixel-aligned object `n` to the pixel grid: its stroke weights become whole points (at
/// least 1), and its horizontal and vertical straight segments move to whole points, or to half
/// points when a centered stroke has an odd weight, so that the stroke's edges fall on the grid.
/// A compound path snaps each of its paths. Placed gradients and linked opacity masks of an object
/// whose anchors moved follow it ([`follow_snap`]).
fn snap_object(n: &mut Node, sc: Scaling) {
let half = round_strokes(n);
let before = n.geometric_bounds();
let mut moved = false;
if let NodeKind::Compound { children, .. } = &mut n.kind {
// A path is copied for editing only when the snap moves it.
for c in children.iter_mut() {
if path_snaps(c, half) {
let was = c.geometric_bounds();
let anchors = snaps_anchors(c);
let snapped = Arc::make_mut(c);
snap_path_node(snapped, half, sc);
if anchors {
follow_snap(snapped, was, sc);
}
moved = true;
}
}
} else if snaps_anchors(n) {
moved = snap_path_node(n, half, sc);
} else {
// A live shape is moved with `Node::transform`, which carries its gradients and mask along.
snap_path_node(n, half, sc);
}
if moved {
follow_snap(n, before, sc);
}
}
/// Whether a snap moves the anchors of path `n` itself (a plain path, or one with Live Corners)
/// rather than transforming it as a whole (another live shape).
fn snaps_anchors(n: &Node) -> bool {
matches!(&n.kind, NodeKind::Path { live, .. } if live.as_deref().is_none_or(|l| matches!(l, LiveShape::Path { .. })))
}
/// After the anchors of `n` moved in a snap, from bounds `before`: its placed gradients are fitted
/// to its new bounds, its patterns move and scale with those bounds when `sc` transforms patterns,
/// and the art of its linked opacity mask moves and scales with them (and snaps when it is
/// pixel-aligned itself).
fn follow_snap(n: &mut Node, before: Option<Rect>, sc: Scaling) {
let (Some(f), Some(t)) = (before, n.geometric_bounds()) else { return };
if f == t {
return;
}
n.appearance.rebase_gradients(f, t);
if sc.patterns {
vectorcraft_doc::pattern::transform_pattern_paints(n, rect_to_rect(f, t));
}
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut())
&& m.linked
{
let art = Arc::make_mut(&mut m.art);
art.transform(rect_to_rect(f, t), sc);
keep_pixel_aligned(art, sc);
}
}
/// Round the stroke weights of `n` to whole points, at least 1 (strokes without weight stay
/// without). Returns whether a visible, painted centered stroke has an odd weight.
fn round_strokes(n: &mut Node) -> bool {
let rounds = |w: f64| if w > 0.0 { w.round().max(1.0) } else { w };
let needs = n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if rounds(st.width) != st.width));
if needs {
for item in &mut n.appearance.items {
if let AppearanceItem::Stroke(st) = item {
st.width = rounds(st.width);
}
}
}
n.appearance.items.iter().any(|i| {
matches!(i, AppearanceItem::Stroke(st) if st.visible
&& !matches!(st.paint, Paint::None)
&& st.width > 0.0
&& st.width % 2.0 == 1.0
&& st.align == StrokeAlign::Center)
})
}
/// Snap path object `n`'s horizontal and vertical straight segments ([`snap_segments`]). A path
/// with Live Corners snaps the outline its corners are cut from. Another live shape is moved and
/// scaled as a whole instead, from its path's bounds to the bounds of the snapped path, so that it
/// stays a live shape. Returns whether it changed.
fn snap_path_node(n: &mut Node, half: bool, sc: Scaling) -> bool {
let NodeKind::Path { path, live, .. } = &mut n.kind else { return false };
let Some(shape) = live.as_deref_mut() else { return snap_segments(path, half) };
if let LiveShape::Path { base, .. } = shape {
if !snap_segments(base, half) {
return false;
}
*path = shape.to_path();
return true;
}
let mut snapped = path.clone();
if !snap_segments(&mut snapped, half) {
return false;
}
let (Some(from), Some(to)) = (path.bounds(), snapped.bounds()) else { return false };
n.transform(rect_to_rect(from, to), Scaling { keep_corners: true, patterns: sc.patterns, ..Scaling::default() });
true
}
/// Whether [`snap_path_node`] would change path object `n`: whether an anchor of the path it snaps
/// moves. Another kind of object is not changed.
fn path_snaps(n: &Node, half: bool) -> bool {
let NodeKind::Path { path, live, .. } = &n.kind else { return false };
match live.as_deref() {
Some(LiveShape::Path { base, .. }) => segment_moves(base, half).is_some(),
_ => segment_moves(path, half).is_some(),
}
}
/// The move and scale that takes `from` onto `to` (a side without size is only moved).
fn rect_to_rect(from: Rect, to: Rect) -> Affine {
let sx = if from.width() > EPS { to.width() / from.width() } else { 1.0 };
let sy = if from.height() > EPS { to.height() / from.height() } else { 1.0 };
Affine::translate((to.x0, to.y0)) * Affine::scale_non_uniform(sx, sy) * Affine::translate((-from.x0, -from.y0))
}
/// Move the anchors of `path`'s horizontal straight segments to whole points vertically and the
/// anchors of its vertical ones to whole points horizontally (`half`: to half points), each anchor
/// with its handles, so curves and diagonal segments keep their shape apart from the anchors they
/// share with those segments. Coordinates that differ stay at least 1 point apart within a subpath,
/// so no side collapses. Returns whether an anchor moved.
pub(crate) fn snap_segments(path: &mut PathData, half: bool) -> bool {
let Some(moves) = segment_moves(path, half) else { return false };
for (sp, m) in path.subpaths.iter_mut().zip(moves) {
for (a, d) in sp.anchors.iter_mut().zip(m) {
if d.x != 0.0 || d.y != 0.0 {
a.translate(d);
}
}
}
true
}
/// How far [`snap_segments`] moves each anchor of each subpath of `path`, or `None` when it moves
/// no anchor.
fn segment_moves(path: &PathData, half: bool) -> Option<Vec<Vec<Vec2>>> {
let mut changed = false;
let mut moves = Vec::with_capacity(path.subpaths.len());
for sp in &path.subpaths {
let n = sp.anchors.len();
let (mut snap_x, mut snap_y) = (vec![false; n], vec![false; n]);
for i in 0..sp.segment_count() {
if !sp.segment_is_line(i) {
continue;
}
let (a, b) = (i % n, (i + 1) % n);
let (p, q) = (sp.anchors[a].p, sp.anchors[b].p);
let (dx, dy) = ((q.x - p.x).abs(), (q.y - p.y).abs());
if dy <= EPS && dx > EPS {
(snap_y[a], snap_y[b]) = (true, true);
} else if dx <= EPS && dy > EPS {
(snap_x[a], snap_x[b]) = (true, true);
}
}
let xs = snapped_values(sp.anchors.iter().zip(&snap_x).filter(|(_, s)| **s).map(|(a, _)| a.p.x), half);
let ys = snapped_values(sp.anchors.iter().zip(&snap_y).filter(|(_, s)| **s).map(|(a, _)| a.p.y), half);
let mut m = Vec::with_capacity(n);
for (i, a) in sp.anchors.iter().enumerate() {
let dx = if snap_x[i] { snapped(&xs, a.p.x) - a.p.x } else { 0.0 };
let dy = if snap_y[i] { snapped(&ys, a.p.y) - a.p.y } else { 0.0 };
changed |= dx != 0.0 || dy != 0.0;
m.push(Vec2::new(dx, dy));
}
moves.push(m);
}
changed.then_some(moves)
}
/// The coordinates `values` and where each snaps to, in increasing order: the nearest whole point
/// (`half`: half point), and at least 1 point past where the coordinate before it snaps when the
/// two differ.
fn snapped_values(values: impl Iterator<Item = f64>, half: bool) -> Vec<(f64, f64)> {
let mut sorted: Vec<f64> = values.filter(|v| v.is_finite()).collect();
sorted.sort_by(f64::total_cmp);
let mut out: Vec<(f64, f64)> = vec![];
for v in sorted {
match out.last() {
Some(&(last, _)) if v - last <= EPS => {}
Some(&(_, at)) => out.push((v, nearest(v, half).max(at + 1.0))),
None => out.push((v, nearest(v, half))),
}
}
out
}
/// The whole point (`half`: half point) nearest `v`. A whole point halfway between two half points
/// goes to the half point above it, and a half point halfway between two whole points goes to the
/// whole point below it, so that switching between the two returns a coordinate to where it was.
fn nearest(v: f64, half: bool) -> f64 {
if half { (v + EPS).floor() + 0.5 } else { (v - 0.5 - EPS).ceil() }
}
/// Where coordinate `v` snaps to, from [`snapped_values`] (`v` itself when it is not listed). A
/// coordinate merged into one listed is at most [`EPS`] past it.
fn snapped(values: &[(f64, f64)], v: f64) -> f64 {
let i = values.partition_point(|(x, _)| *x < v - EPS);
values.get(i).filter(|(x, _)| (x - v).abs() <= EPS).map_or(v, |(_, to)| *to)
}
+3
View File
@@ -254,6 +254,9 @@ pub(super) fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
};
recut(path, live, partial, &picked, radius, kind);
}
for id in &ids {
super::pixelgrid::resnap(d, *id);
}
Ok(())
})?;
ok()
+8 -1
View File
@@ -70,10 +70,12 @@ pub(crate) fn transforms_objects(p: &Value) -> bool {
/// check them with [`check_switches`] before they change anything.
pub(super) const TRANSFORM_SWITCHES: [&str; 6] = ["copy", "strokes", "corners", "patterns", "objects", "typeAreas"];
/// `xf` on `n` with Transform Objects (`objects`, see [`transforms_objects`]) on or off.
/// `xf` on `n` with Transform Objects (`objects`, see [`transforms_objects`]) on or off. The objects
/// in `n` that are aligned to the pixel grid snap to it again ([`super::pixelgrid::keep_pixel_aligned`]).
pub(crate) fn transform_node(n: &mut Node, xf: Affine, sc: Scaling, objects: bool) {
if objects {
n.transform(xf, sc);
super::pixelgrid::keep_pixel_aligned(n, sc);
} else if sc.patterns {
vectorcraft_doc::pattern::transform_patterns_only(n, xf);
}
@@ -168,6 +170,11 @@ fn apply_transform_moving_guides(
transform_node(n, xf, sc, objects);
}
}
if objects {
for id in &targets {
super::pixelgrid::keep_compound_aligned(d, *id, sc);
}
}
if with_guides && (delta.x != 0.0 || delta.y != 0.0) {
for i in sel.guides.clone() {
if let Some(g) = d.guides.get_mut(i) {
+12 -4
View File
@@ -314,13 +314,18 @@ fn expand(s: &mut Session, p: &Value) -> Result<Value> {
/// After an edit: put the objects of every Objects on Path group that changed back on its path
/// ([`place`]), inner groups first. Only the parts of the tree that the edit changed are searched.
pub(crate) fn refresh(before: &Document, doc: &mut Document) {
/// Returns the groups that placing changed.
pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Vec<NodeId> {
let mut groups = vec![];
changed_groups(&before.layers, &doc.layers, &mut groups);
let mut placed = vec![];
for id in groups.iter().rev() {
follow_replacements(before, doc, *id);
place_in(doc, *id);
if place_in(doc, *id) {
placed.push(*id);
}
}
placed
}
/// Members of the group `id` that the edit replaced with new art at the same index keep their
@@ -339,14 +344,17 @@ fn follow_replacements(before: &Document, doc: &mut Document, id: NodeId) {
}
/// [`place`] the Objects on Path group `id` of `doc`, measuring symbol instances by their art.
fn place_in(doc: &mut Document, id: NodeId) {
let Some(n) = doc.node(id) else { return };
/// Returns whether the group changed.
fn place_in(doc: &mut Document, id: NodeId) -> bool {
let Some(n) = doc.node(id) else { return false };
let mut g = n.clone();
if place(&mut g, &doc.symbol_boxes())
&& let Some(slot) = doc.node_mut(id)
{
*slot = g;
return true;
}
false
}
/// The Objects on Path groups among `after` and inside them that are not the same nodes as before
+3
View File
@@ -577,6 +577,9 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
// A stroke without paint has no weight; painting it again restores the 1 pt default.
stroke.width = 0.0;
}
if !fill {
super::object::snap_if_aligned(n);
}
Ok(())
},
|d, changed| {
+4 -1
View File
@@ -377,7 +377,10 @@ pub(crate) fn copy_artboard(d: &mut vectorcraft_doc::Document, i: usize, dv: Vec
if let Some(id) = d.artboards.get(index).map(|a| a.id) {
d.copy_artboard_guides(src.id, id, dv);
}
let copies = super::edit::copy_above(d, art, |_, _, c| c.transform(Affine::translate(dv), sc))?;
let copies = super::edit::copy_above(d, art, |_, _, c| {
c.transform(Affine::translate(dv), sc);
super::object::keep_pixel_aligned(c, sc);
})?;
Ok((index, copies))
}
+15
View File
@@ -262,11 +262,19 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge
*a = a.transform(xf);
}
}
// A pixel-aligned path, or the pixel-aligned compound path that holds it, snaps
// to the pixel grid again after its anchors move.
let top = compound_or_self(doc, *id);
if let Some(n) = doc.node_mut(top) {
super::object::snap_if_aligned(n);
}
}
None => {
if let Some(n) = doc.node_mut(*id) {
n.transform(xf, sc);
super::object::keep_pixel_aligned(n, sc);
}
super::object::keep_compound_aligned(doc, *id, sc);
}
}
}
@@ -308,6 +316,12 @@ fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let path = path_mut(d, id)?;
let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?;
*a = a.transform(vectorcraft_geom::Affine::translate(pos - a.p));
// A pixel-aligned path, or the pixel-aligned compound path that holds it, snaps to the
// pixel grid again.
let top = compound_or_self(d, id);
if let Some(n) = d.node_mut(top) {
super::object::snap_if_aligned(n);
}
Ok(())
})?;
ok()
@@ -337,6 +351,7 @@ fn set_anchors(s: &mut Session, p: &Value) -> Result<Value> {
);
s.edit("Reshape", |d, _| {
*path_mut(d, id)? = data;
super::object::resnap(d, id);
Ok(())
})?;
ok()
+2
View File
@@ -708,7 +708,9 @@ fn move_slices(s: &mut Session, p: &Value) -> Result<Value> {
None => {
if let Some(n) = d.node_mut(*id) {
n.transform(vectorcraft_geom::Affine::translate(v), vectorcraft_doc::Scaling::default());
super::object::keep_pixel_aligned(n, vectorcraft_doc::Scaling::default());
}
super::object::keep_compound_aligned(d, *id, vectorcraft_doc::Scaling::default());
}
}
}
+2
View File
@@ -266,6 +266,7 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(st) = n.appearance.stroke_at_mut(index) {
set(st);
}
super::object::snap_if_aligned(n);
Ok(())
})?;
// Commit the new-art template only after the complete request is validated
@@ -329,6 +330,7 @@ fn stroke_step(s: &mut Session, p: &Value, up: bool) -> Result<Value> {
if let Some(st) = n.appearance.stroke_at_mut(index) {
st.width = step_weight(st.width, unit, up);
}
super::object::snap_if_aligned(n);
Ok(())
})?;
Ok(json!({"objects": ids.iter().collect::<std::collections::BTreeSet<_>>().len()}))
+1
View File
@@ -273,6 +273,7 @@ fn apply_style(d: &mut Document, id: NodeId, g: &GraphicStyle, chars: bool) {
if let Some(n) = d.node_mut(id) {
style_node(n, g, chars);
n.extras_mut().graphic_style = Some(g.id);
super::object::snap_if_aligned(n);
}
}
+1
View File
@@ -414,6 +414,7 @@ fn artboard_move(s: &mut Session, p: &Value) -> Result<Value> {
for id in art {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(dv), sc);
super::object::keep_pixel_aligned(n, sc);
}
}
index.push(*i);
+8 -2
View File
@@ -26,7 +26,11 @@ impl Session {
if let Some(ge) = &st.global_edit {
spread = cmd::globaledit::propagate(&before, doc, ge, [&before_sel.objects, &st.selection.objects]);
}
cmd::objects_on_path::refresh(&before, doc);
// Pixel-aligned objects the edit changed go back on the pixel grid.
cmd::refresh_pixel_aligned(&before, doc);
let placed = cmd::objects_on_path::refresh(&before, doc);
// Pixel-aligned members that placing moved go back on the pixel grid.
cmd::keep_aligned_in(doc, &placed);
dim_changed = cmd::dimension::refresh(&before, doc)?;
Ok(v)
}) {
@@ -35,7 +39,9 @@ impl Session {
Ok(shaper_changed) => {
// Attached Shaper groups whose art was rebuilt go back on their path.
if shaper_changed || dim_changed {
cmd::objects_on_path::refresh(&before, Arc::make_mut(&mut st.doc));
let doc = Arc::make_mut(&mut st.doc);
let placed = cmd::objects_on_path::refresh(&before, doc);
cmd::keep_aligned_in(doc, &placed);
}
// Symbol editing: the symbol follows its art on the editing layer, before
// inline graphics follow their symbols.
+1
View File
@@ -125,6 +125,7 @@ mod persp_planes;
pub(super) mod persp_select;
mod persp_text;
mod perspgrid;
mod pixelalign;
mod place;
mod placed_document;
mod plugins;
+768
View File
@@ -0,0 +1,768 @@
//! Transform panel › Align to Pixel Grid (`object.setPixelAligned`): a pixel-aligned path keeps its
//! horizontal and vertical segments on the pixel grid through transforms.
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind};
use crate::Session;
fn new_doc() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s
}
/// A closed path from SVG path data `d`, selected, with a stroke of `weight` points.
fn path(s: &mut Session, d: &str, weight: f64) -> u64 {
let id = s.execute("path.create", &json!({"d": d})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("stroke.set", &json!({"weight": weight})).unwrap();
id
}
/// The anchor points of path object `id`, every subpath's in order.
fn points(s: &Session, id: u64) -> Vec<(f64, f64)> {
let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone();
n.path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect()
}
fn aligned(s: &Session, id: u64) -> bool {
s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().pixel_aligned
}
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
/// Whether every coordinate is a whole number of points (within rounding error).
fn whole(points: &[(f64, f64)]) -> bool {
points.iter().all(|(x, y)| (x - x.round()).abs() < 1e-9 && (y - y.round()).abs() < 1e-9)
}
fn close(a: (f64, f64), b: (f64, f64)) -> bool {
(a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9
}
/// Turning it on sets the property and snaps the path's horizontal and vertical edges to whole
/// points, in one undo step; turning it on again changes nothing and makes no undo step; undo
/// puts the path and the property back.
#[test]
fn turning_it_on_snaps_the_path_in_one_undo_step() {
let mut s = new_doc();
let id = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0);
let before = points(&s, id);
let steps = undo_len(&s);
let r = s.execute("object.setPixelAligned", &json!({"on": true})).unwrap();
assert_eq!(r["ids"], json!([id]));
assert!(aligned(&s, id));
assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]);
assert_eq!(undo_len(&s), steps + 1);
let r = s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap();
assert_eq!(r["ids"], json!([]));
assert_eq!(undo_len(&s), steps + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert!(!aligned(&s, id));
assert_eq!(points(&s, id), before);
// An odd stroke weight centered on the path puts the edges on half points, and the stroke
// weight becomes whole.
s.execute("stroke.set", &json!({"weight": 0.8})).unwrap();
s.execute("object.setPixelAligned", &json!({"on": true})).unwrap();
assert_eq!(points(&s, id), [(10.5, 20.5), (40.5, 20.5), (40.5, 30.5), (10.5, 30.5)]);
assert_eq!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.stroke_width(), 1.0);
// Off keeps the geometry.
s.execute("object.setPixelAligned", &json!({"on": false})).unwrap();
assert!(!aligned(&s, id));
assert_eq!(points(&s, id), [(10.5, 20.5), (40.5, 20.5), (40.5, 30.5), (10.5, 30.5)]);
}
/// A pixel-aligned path stays on the pixel grid after a fractional move, a scale, a rotation about
/// a fractional point, a reflection, Transform Each and a move of its group; one that is not
/// moves exactly as before.
#[test]
fn it_stays_aligned_through_transforms() {
let mut s = new_doc();
let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0);
let other = path(&mut s, "M10 121 L41 121 L41 130 L10 130 Z", 2.0);
s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap();
s.execute("select.set", &json!({"ids": [id, other]})).unwrap();
s.execute("object.move", &json!({"dx": 0.3, "dy": 0.45})).unwrap();
assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]);
assert!(close(points(&s, other)[0], (10.3, 121.45)), "{:?}", points(&s, other));
s.execute("select.set", &json!({"ids": [id]})).unwrap();
for (cmd, p) in [
("object.move", json!({"dx": 5.7, "dy": -2.2})),
("object.scale", json!({"sx": 133, "sy": 71})),
("object.rotate", json!({"angle": 90, "origin": [20.2, 20.7]})),
("object.reflect", json!({"axis": "horizontal", "origin": [0.3, 0.3]})),
("object.transformEach", json!({"moveH": 0.4, "moveV": 0.35})),
("object.setBounds", json!({"width": 17.3})),
] {
s.execute(cmd, &p).unwrap();
let pts = points(&s, id);
assert!(whole(&pts), "{cmd}: {pts:?}");
let xs: Vec<f64> = pts.iter().map(|p| p.0).collect();
assert!(xs.iter().any(|x| (x - xs[0]).abs() >= 1.0), "{cmd}: the path collapsed: {pts:?}");
}
// In a group that moves.
s.execute("select.set", &json!({"ids": [id, other]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
let before = points(&s, other);
s.execute("object.move", &json!({"dx": 0.25, "dy": 0.0})).unwrap();
assert!(whole(&points(&s, id)), "{:?}", points(&s, id));
assert!(close(points(&s, other)[0], (before[0].0 + 0.25, before[0].1)));
}
/// Art moved with its artboard stays on the pixel grid.
#[test]
fn it_stays_aligned_when_its_artboard_moves() {
let mut s = new_doc();
let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0);
s.execute("object.setPixelAligned", &json!({})).unwrap();
s.execute("artboard.move", &json!({"index": 0, "dx": 0.4, "dy": 0.3, "moveArt": true})).unwrap();
assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]);
s.execute("artboard.move", &json!({"index": 0, "dx": 0.6, "dy": 0.7, "moveArt": true})).unwrap();
assert_eq!(points(&s, id), [(11.0, 22.0), (42.0, 22.0), (42.0, 31.0), (11.0, 31.0)]);
}
/// Diagonal segments and curves keep their shape: only the anchors that horizontal and vertical
/// segments share move, each with its handles.
#[test]
fn diagonal_segments_and_curves_keep_their_shape() {
let mut s = new_doc();
// A triangle: a horizontal base and two diagonal sides. Only the base's y snaps.
let tri = path(&mut s, "M0.2 0.2 L20.4 0.2 L10.3 15.7 Z", 2.0);
s.execute("object.setPixelAligned", &json!({})).unwrap();
let pts = points(&s, tri);
assert!(close(pts[0], (0.2, 0.0)) && close(pts[1], (20.4, 0.0)) && close(pts[2], (10.3, 15.7)), "{pts:?}");
s.execute("object.move", &json!({"dx": 0.5, "dy": 0.3})).unwrap();
let pts = points(&s, tri);
assert!(close(pts[0], (0.7, 0.0)) && close(pts[1], (20.9, 0.0)) && close(pts[2], (10.8, 16.0)), "{pts:?}");
// A curve between two horizontal segments: its ends move with them and its handles with its
// ends.
let curved = path(&mut s, "M100.3 100.3 L120.3 100.3 C125 105 125 115 120.3 120.3 L100.3 120.3 Z", 2.0);
s.execute("object.setPixelAligned", &json!({})).unwrap();
let n = s.doc().unwrap().doc.node(NodeId(curved)).unwrap().clone();
let sp = &n.path_data().unwrap().subpaths[0];
let a = &sp.anchors;
assert!(close((a[0].p.x, a[0].p.y), (100.0, 100.0)) && close((a[3].p.x, a[3].p.y), (100.0, 120.0)));
assert!(close((a[1].p.x, a[1].p.y), (120.3, 100.0)) && close((a[1].h_out.x, a[1].h_out.y), (125.0, 104.7)));
assert!(close((a[2].p.x, a[2].p.y), (120.3, 120.0)) && close((a[2].h_in.x, a[2].h_in.y), (125.0, 114.7)));
// A rectangle turned 45° has no horizontal or vertical segment, and stays as it is.
let turned = path(&mut s, "M200.5 200 L210.5 210.3 L200.5 220.6 L190.5 210.3 Z", 2.0);
let before = points(&s, turned);
s.execute("object.setPixelAligned", &json!({})).unwrap();
assert_eq!(points(&s, turned), before);
}
/// Live shapes stay live, and compound paths snap each of their paths.
#[test]
fn live_shapes_and_compound_paths_snap() {
let mut s = new_doc();
let rect = s.execute("shape.rectangle", &json!({"x": 10.3, "y": 20.6, "width": 30.4, "height": 9.7})).unwrap()["id"].as_u64().unwrap();
s.execute("stroke.set", &json!({"weight": 2})).unwrap();
s.execute("object.setPixelAligned", &json!({})).unwrap();
let n = s.doc().unwrap().doc.node(NodeId(rect)).unwrap().clone();
assert!(n.live_shape().is_some());
let b = n.geometric_bounds().unwrap();
assert_eq!((b.x0, b.y0, b.x1, b.y1), (10.0, 21.0, 41.0, 30.0));
s.execute("object.move", &json!({"dx": 0.4, "dy": 0.4})).unwrap();
let b = s.doc().unwrap().doc.node(NodeId(rect)).unwrap().geometric_bounds().unwrap();
assert_eq!((b.x0, b.y0, b.x1, b.y1), (10.0, 21.0, 41.0, 30.0));
let a = path(&mut s, "M100.2 100.2 L110.6 100.2 L110.6 110.6 L100.2 110.6 Z", 2.0);
let b = path(&mut s, "M200.2 100.2 L210.6 100.2 L210.6 110.6 L200.2 110.6 Z", 2.0);
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap();
s.execute("object.setPixelAligned", &json!({"ids": [c]})).unwrap();
s.execute("object.move", &json!({"dx": 0.3, "dy": 0.0})).unwrap();
let n = s.doc().unwrap().doc.node(NodeId(c)).unwrap().clone();
let pts: Vec<(f64, f64)> =
n.children().unwrap().iter().flat_map(|k| k.path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect::<Vec<_>>()).collect();
assert_eq!(pts.len(), 8);
assert!(whole(&pts), "{pts:?}");
}
/// The anchor points of every path in compound path `id`.
fn compound_points(s: &Session, id: u64) -> Vec<(f64, f64)> {
let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone();
n.children().unwrap().iter().flat_map(|k| k.path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect::<Vec<_>>()).collect()
}
/// A stroke edit on a pixel-aligned path rounds the new weight and snaps the path again: a 0.8 pt
/// weight becomes 1 pt with the edges on half points, a centered 3 pt stroke keeps the edges on
/// half points, and Stroke Weight Up keeps the weight whole; all in the stroke edit's one undo step.
#[test]
fn stroke_edits_keep_it_aligned() {
let mut s = new_doc();
let id = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0);
s.execute("object.setPixelAligned", &json!({})).unwrap();
assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]);
let steps = undo_len(&s);
s.execute("stroke.set", &json!({"weight": 0.8})).unwrap();
assert_eq!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.stroke_width(), 1.0);
assert_eq!(points(&s, id), [(10.5, 21.5), (41.5, 21.5), (41.5, 30.5), (10.5, 30.5)]);
assert_eq!(undo_len(&s), steps + 1);
s.execute("stroke.set", &json!({"weight": 2})).unwrap();
assert!(whole(&points(&s, id)), "{:?}", points(&s, id));
s.execute("stroke.set", &json!({"weight": 3})).unwrap();
let pts = points(&s, id);
assert!(pts.iter().all(|(x, y)| (x - x.floor() - 0.5).abs() < 1e-9 && (y - y.floor() - 0.5).abs() < 1e-9), "{pts:?}");
// Inside alignment puts the stroke within the path, so the anchors go back to whole points.
s.execute("stroke.set", &json!({"align": "inside"})).unwrap();
assert!(whole(&points(&s, id)), "{:?}", points(&s, id));
s.execute("stroke.set", &json!({"align": "center", "weight": 0.6})).unwrap();
s.execute("stroke.weightUp", &json!({})).unwrap();
let w = s.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.stroke_width();
assert_eq!(w, w.round(), "{w}");
// A path that is not pixel-aligned takes the weight as given.
let plain = path(&mut s, "M110.3 20.6 L140.7 20.6 L140.7 30.3 L110.3 30.3 Z", 0.8);
assert_eq!(s.doc().unwrap().doc.node(NodeId(plain)).unwrap().appearance.stroke_width(), 0.8);
assert_eq!(points(&s, plain), [(110.3, 20.6), (140.7, 20.6), (140.7, 30.3), (110.3, 30.3)]);
}
/// Make Compound Path from pixel-aligned paths gives a pixel-aligned compound path whose paths do
/// not keep the property; they snap with the compound path's stroke, so a centered 1 pt stroke puts
/// them on half points after a move. Release gives each path the property back.
#[test]
fn a_compound_path_of_aligned_paths_is_aligned() {
let mut s = new_doc();
let a = path(&mut s, "M100.2 100.2 L110.6 100.2 L110.6 110.6 L100.2 110.6 Z", 1.0);
let b = path(&mut s, "M200.2 100.2 L210.6 100.2 L210.6 110.6 L200.2 110.6 Z", 1.0);
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("object.setPixelAligned", &json!({})).unwrap();
let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap();
assert!(aligned(&s, c));
let n = s.doc().unwrap().doc.node(NodeId(c)).unwrap().clone();
assert!(n.children().unwrap().iter().all(|k| !k.extras().pixel_aligned));
let half = |pts: &[(f64, f64)]| pts.iter().all(|(x, y)| (x - x.floor() - 0.5).abs() < 1e-9 && (y - y.floor() - 0.5).abs() < 1e-9);
assert!(half(&compound_points(&s, c)), "{:?}", compound_points(&s, c));
s.execute("object.move", &json!({"dx": 0.3, "dy": 0.2})).unwrap();
let pts = compound_points(&s, c);
assert_eq!(pts.len(), 8);
assert!(half(&pts), "{pts:?}");
// A path inside the compound path cannot take the property alone.
let member = n.children().unwrap()[0].id.0;
let e = s.execute("object.setPixelAligned", &json!({"ids": [member]})).unwrap_err().to_string();
assert!(e.contains("only paths and compound paths"), "{e}");
s.execute("select.set", &json!({"ids": [c]})).unwrap();
s.execute("object.compoundPath.release", &json!({})).unwrap();
let released = s.doc().unwrap().selection.objects.clone();
assert_eq!(released.len(), 2);
assert!(released.iter().all(|id| aligned(&s, id.0)));
}
/// Make Compound Path from a pixel-aligned compound path and another path gives a pixel-aligned
/// compound path, which keeps snapping after a move.
#[test]
fn a_compound_path_of_an_aligned_compound_path_is_aligned() {
let mut s = new_doc();
let a = path(&mut s, "M100.2 100.2 L110.6 100.2 L110.6 110.6 L100.2 110.6 Z", 2.0);
let b = path(&mut s, "M200.2 100.2 L210.6 100.2 L210.6 110.6 L200.2 110.6 Z", 2.0);
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap();
s.execute("object.setPixelAligned", &json!({"ids": [c]})).unwrap();
let other = path(&mut s, "M300.2 100.2 L310.6 100.2 L310.6 110.6 L300.2 110.6 Z", 2.0);
s.execute("select.set", &json!({"ids": [c, other]})).unwrap();
let joined = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap();
assert!(aligned(&s, joined));
s.execute("object.move", &json!({"dx": 0.3, "dy": 0.4})).unwrap();
let pts = compound_points(&s, joined);
assert_eq!(pts.len(), 12);
assert!(whole(&pts), "{pts:?}");
}
/// A stroke with no paint does not count: a fill-only path whose stroke item keeps an odd weight
/// snaps to whole points.
#[test]
fn a_stroke_without_paint_snaps_to_whole_points() {
let mut s = new_doc();
let id = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 1.0);
// An imported file can hold a stroke item without paint that keeps its weight.
let n = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(NodeId(id)).unwrap();
let Some(vectorcraft_doc::AppearanceItem::Stroke(st)) =
n.appearance.items.iter_mut().find(|i| matches!(i, vectorcraft_doc::AppearanceItem::Stroke(_)))
else {
panic!("no stroke");
};
st.paint = vectorcraft_color::Paint::None;
assert_eq!(st.width, 1.0);
s.execute("object.setPixelAligned", &json!({})).unwrap();
assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]);
}
/// With direct-selected anchors in the selection, a move snaps the pixel-aligned path whose anchors
/// moved and the pixel-aligned object selected whole.
#[test]
fn moving_direct_selected_anchors_keeps_it_aligned() {
let mut s = new_doc();
let a = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0);
let b = path(&mut s, "M110.3 20.6 L140.7 20.6 L140.7 30.3 L110.3 30.3 Z", 2.0);
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("object.setPixelAligned", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [b]})).unwrap();
s.execute("select.anchors", &json!({"id": a, "anchors": [[0, 1], [0, 2]], "mode": "add"})).unwrap();
s.execute("path.moveAnchors", &json!({"dx": 0.4, "dy": 0.0})).unwrap();
assert_eq!(points(&s, a), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]);
assert_eq!(points(&s, b), [(110.0, 21.0), (141.0, 21.0), (141.0, 30.0), (110.0, 30.0)]);
s.execute("path.moveAnchors", &json!({"dx": 0.7, "dy": 0.0})).unwrap();
assert_eq!(points(&s, a), [(10.0, 21.0), (42.0, 21.0), (42.0, 30.0), (10.0, 30.0)]);
assert_eq!(points(&s, b), [(111.0, 21.0), (142.0, 21.0), (142.0, 30.0), (111.0, 30.0)]);
}
/// A path with Live Corners snaps the outline its corners are cut from, inner edges included, and
/// keeps its Live Corners.
#[test]
fn a_path_with_live_corners_snaps_its_inner_edges() {
let mut s = new_doc();
let id = path(&mut s, "M10 10 L40 10 L40 20.3 L25 20.3 L25 40 L10 40 Z", 2.0);
s.execute("object.setLiveShape", &json!({"id": id, "radius": 2})).unwrap();
let base = |s: &Session| {
let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone();
let NodeKind::Path { live: Some(live), .. } = &n.kind else { panic!("no Live Corners") };
let vectorcraft_doc::LiveShape::Path { base, .. } = &**live else { panic!("not a path with Live Corners") };
base.anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect::<Vec<_>>()
};
assert!(base(&s).iter().any(|p| close(*p, (40.0, 20.3))));
s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap();
assert_eq!(base(&s), [(10.0, 10.0), (40.0, 10.0), (40.0, 20.0), (25.0, 20.0), (25.0, 40.0), (10.0, 40.0)]);
let pts = points(&s, id);
assert!(pts.iter().all(|p| (p.1 - 20.3).abs() > 1e-6), "{pts:?}");
}
/// `path.setAnchors` and `object.setLiveShape` snap a pixel-aligned path again after they replace
/// its geometry.
#[test]
fn geometry_edits_keep_it_aligned() {
let mut s = new_doc();
let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0);
s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap();
let anchors = json!([{"x": 10.3, "y": 21.4}, {"x": 41.3, "y": 21.4}, {"x": 41.3, "y": 30.4}, {"x": 10.3, "y": 30.4}]);
s.execute("path.setAnchors", &json!({"id": id, "subpaths": [{"anchors": anchors, "closed": true}]})).unwrap();
assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]);
let poly = s.execute("shape.polygon", &json!({"cx": 100, "cy": 100, "radius": 50, "sides": 6})).unwrap()["id"].as_u64().unwrap();
s.execute("object.setPixelAligned", &json!({"ids": [poly]})).unwrap();
s.execute("object.setLiveShape", &json!({"id": poly, "sides": 5})).unwrap();
let n = s.doc().unwrap().doc.node(NodeId(poly)).unwrap().clone();
let b = n.geometric_bounds().unwrap();
let on_grid = |v: f64| ((v - 0.5) - (v - 0.5).round()).abs() < 1e-9 || (v - v.round()).abs() < 1e-9;
assert!(on_grid(b.y1), "{b:?}");
}
/// Any other edit of a pixel-aligned path, here Object › Path › Average, snaps it again too.
#[test]
fn other_path_edits_keep_it_aligned() {
let mut s = new_doc();
let a = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0);
let b = path(&mut s, "M60.3 50 L80 50 L80 60 L60.3 60 Z", 2.0);
s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap();
s.execute("select.set", &json!({"ids": []})).unwrap();
s.execute("select.anchors", &json!({"id": a, "anchors": [[0, 1], [0, 2]], "mode": "add"})).unwrap();
s.execute("select.anchors", &json!({"id": b, "anchors": [[0, 0]], "mode": "add"})).unwrap();
s.execute("path.average", &json!({"axis": "vertical"})).unwrap();
assert_eq!(points(&s, a), [(10.0, 21.0), (47.0, 21.0), (47.0, 30.0), (10.0, 30.0)]);
assert!((points(&s, b)[0].0 - (41.0 + 41.0 + 60.3) / 3.0).abs() < 1e-9, "{:?}", points(&s, b));
}
/// A stroke edit that Global Edit carries to the other members snaps the pixel-aligned ones.
#[test]
fn global_edit_keeps_its_members_aligned() {
let mut s = new_doc();
let a = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0);
// The same shape at twice the size.
let b = path(&mut s, "M110 21 L172 21 L172 39 L110 39 Z", 2.0);
s.execute("object.setPixelAligned", &json!({"ids": [a, b]})).unwrap();
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let r = s.execute("select.globalEdit", &json!({})).unwrap();
assert_eq!(r["active"], true);
s.execute("stroke.set", &json!({"weight": 1})).unwrap();
assert_eq!(strokes(&s, b)[0].1, 1.0);
assert_eq!(points(&s, a), [(10.5, 21.5), (41.5, 21.5), (41.5, 30.5), (10.5, 30.5)]);
assert!(halves(&points(&s, b)), "{:?}", points(&s, b));
}
/// Align with direct-selected anchors snaps the pixel-aligned path whose anchors moved.
#[test]
fn aligning_direct_selected_anchors_keeps_it_aligned() {
let mut s = new_doc();
let a = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0);
let b = path(&mut s, "M60.4 50.3 L80 50.3 L80 60 L60.4 60 Z", 2.0);
s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap();
s.execute("select.set", &json!({"ids": []})).unwrap();
s.execute("select.anchors", &json!({"id": a, "anchors": [[0, 1], [0, 2]], "mode": "add"})).unwrap();
s.execute("select.anchors", &json!({"id": b, "anchors": [[0, 0]], "mode": "add"})).unwrap();
s.execute("object.align", &json!({"horizontal": "right"})).unwrap();
assert_eq!(points(&s, a), [(10.0, 21.0), (60.0, 21.0), (60.0, 30.0), (10.0, 30.0)]);
assert!(close(points(&s, b)[0], (60.4, 50.3)), "{:?}", points(&s, b));
}
/// The art of a linked opacity mask moves and scales with a path that snaps, and a placed gradient
/// keeps its place too.
#[test]
fn a_linked_opacity_mask_follows_a_snap() {
let mut s = new_doc();
let o = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0);
let m = path(&mut s, "M12 23 L39 23 L39 28 L12 28 Z", 2.0);
s.execute("transparency.makeOpacityMask", &json!({"ids": [o, m]})).unwrap();
let mask_bounds = |s: &Session| {
let n = s.doc().unwrap().doc.node(NodeId(o)).unwrap().clone();
n.extras().mask.clone().expect("an opacity mask").art.geometric_bounds().unwrap()
};
let (from, mask) = (s.doc().unwrap().doc.node(NodeId(o)).unwrap().geometric_bounds().unwrap(), mask_bounds(&s));
s.execute("object.setPixelAligned", &json!({"ids": [o]})).unwrap();
let to = s.doc().unwrap().doc.node(NodeId(o)).unwrap().geometric_bounds().unwrap();
assert_eq!(points(&s, o), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]);
let at = |r: vectorcraft_geom::Rect, x: f64, y: f64| ((x - r.x0) / r.width(), (y - r.y0) / r.height());
let after = mask_bounds(&s);
assert!(close(at(to, after.x0, after.y0), at(from, mask.x0, mask.y0)), "{after:?} from {mask:?}");
assert!(close(at(to, after.x1, after.y1), at(from, mask.x1, mask.y1)), "{after:?} from {mask:?}");
}
/// `path.moveAnchor` (the Anchor Point tool, Smart Pen) snaps a pixel-aligned path again: two moves
/// that leave a horizontal segment off the grid put it back on whole points.
#[test]
fn moving_one_anchor_keeps_it_aligned() {
let mut s = new_doc();
let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0);
s.execute("object.setPixelAligned", &json!({})).unwrap();
s.execute("path.moveAnchor", &json!({"id": id, "anchor": 0, "x": 10, "y": 21.3})).unwrap();
s.execute("path.moveAnchor", &json!({"id": id, "anchor": 1, "x": 41, "y": 21.3})).unwrap();
assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]);
}
/// A path of a pixel-aligned compound path moved on its own (Group Selection) snaps with the
/// compound path.
#[test]
fn moving_a_path_of_an_aligned_compound_path_keeps_it_aligned() {
let mut s = new_doc();
let a = path(&mut s, "M100 100 L110 100 L110 110 L100 110 Z", 2.0);
let b = path(&mut s, "M200 100 L210 100 L210 110 L200 110 Z", 2.0);
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap();
s.execute("object.setPixelAligned", &json!({"ids": [c]})).unwrap();
let n = s.doc().unwrap().doc.node(NodeId(c)).unwrap().clone();
let member = n.children().unwrap()[0].id.0;
s.execute("select.set", &json!({"ids": [member]})).unwrap();
s.execute("object.move", &json!({"dx": 0.3, "dy": 0.4})).unwrap();
let pts = compound_points(&s, c);
assert_eq!(pts.len(), 8);
assert!(whole(&pts), "{pts:?}");
}
/// A path that Paste in Front puts inside a pixel-aligned compound path, or that an object slice
/// move moves there, snaps with the compound path's stroke, and a pasted path drops its own
/// property there.
#[test]
fn paths_added_to_or_moved_in_an_aligned_compound_path_keep_it_aligned() {
let mut s = new_doc();
let a = path(&mut s, "M100 100 L110 100 L110 110 L100 110 Z", 1.0);
let b = path(&mut s, "M200 100 L210 100 L210 110 L200 110 Z", 1.0);
let loose = path(&mut s, "M300 100 L310 100 L310 110 L300 110 Z", 2.0);
s.execute("object.setPixelAligned", &json!({"ids": [loose]})).unwrap();
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap();
s.execute("object.setPixelAligned", &json!({"ids": [c]})).unwrap();
assert!(halves(&compound_points(&s, c)));
let member = s.doc().unwrap().doc.node(NodeId(c)).unwrap().children().unwrap()[0].id.0;
s.execute("select.set", &json!({"ids": [loose]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [member]})).unwrap();
s.execute("edit.pasteInFront", &json!({})).unwrap();
let pasted = s.doc().unwrap().selection.objects[0];
assert_eq!(s.doc().unwrap().doc.parent_of(pasted), Some(NodeId(c)));
assert!(!aligned(&s, pasted.0));
let pts = compound_points(&s, c);
assert_eq!(pts.len(), 12);
assert!(halves(&pts), "paste: {pts:?}");
s.execute("object.slice.make", &json!({"ids": [member]})).unwrap();
s.execute("object.slice.move", &json!({"slices": [member], "dx": 0.3, "dy": 0.4})).unwrap();
let pts = compound_points(&s, c);
assert!(halves(&pts), "slice: {pts:?}");
}
/// Art moved by Artboard Options with Move Artwork with Artboard, and the art copies of an
/// artboard copied with its art, stay on the pixel grid.
#[test]
fn artboard_options_and_artboard_copies_keep_it_aligned() {
let mut s = new_doc();
let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0);
s.execute("object.setPixelAligned", &json!({})).unwrap();
let r = s.doc().unwrap().doc.artboards[0].rect;
s.execute("artboard.setProps", &json!({"index": 0, "x": r.x0 + 0.4, "y": r.y0 + 0.3, "moveArt": true})).unwrap();
assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]);
let out = s.execute("artboard.move", &json!({"index": 0, "dx": 500.4, "dy": 0.3, "copy": true, "moveArt": true})).unwrap();
let copies = out["moved"].as_array().unwrap();
assert_eq!(copies.len(), 1);
let copy = copies[0].as_u64().unwrap();
assert!(aligned(&s, copy));
assert!(whole(&points(&s, copy)), "{:?}", points(&s, copy));
assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]);
}
/// Type, images and groups cannot be aligned: the command is an error and nothing changes, also
/// for a path selected with one of them.
#[test]
fn type_and_images_cannot_be_aligned() {
let mut s = new_doc();
let p = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0);
let before = points(&s, p);
let text = s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Hi"})).unwrap()["id"].as_u64().unwrap();
let png = crate::tests_links::png(4, 4, [255, 0, 0]);
s.execute("file.place", &json!({"name": "red.png", "dataBase64": vectorcraft_format::base64_encode(&png), "link": false})).unwrap();
let image = {
let st = s.doc().unwrap();
let id = st.selection.objects[0];
assert!(matches!(st.doc.node(id).unwrap().kind, NodeKind::Image(_)));
id.0
};
let steps = undo_len(&s);
for ids in [json!([text]), json!([image]), json!([p, text])] {
let e = s.execute("object.setPixelAligned", &json!({"ids": ids})).unwrap_err().to_string();
assert!(e.contains("only paths and compound paths"), "{ids}: {e}");
assert_eq!(undo_len(&s), steps, "{ids}");
assert!(!aligned(&s, p));
assert_eq!(points(&s, p), before);
}
let e = s.execute("object.setPixelAligned", &json!({"ids": [p], "on": "yes"})).unwrap_err().to_string();
assert!(e.contains("`on` must be true or false"), "{e}");
assert_eq!(undo_len(&s), steps);
}
/// The property is saved in the native file and read back; an object without it is saved without
/// the key and reads back off.
#[test]
fn the_property_is_saved_and_read_back() {
let mut s = new_doc();
let a = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0);
let b = path(&mut s, "M110.3 20.6 L140.7 20.6 L140.7 30.3 L110.3 30.3 Z", 2.0);
s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap();
let d = &s.doc().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
assert!(back.node(NodeId(a)).unwrap().extras().pixel_aligned);
assert!(!back.node(NodeId(b)).unwrap().extras().pixel_aligned);
let plain: Value = serde_json::to_value(d.node(NodeId(b)).unwrap()).unwrap();
assert!(plain.get("pixel_aligned").is_none(), "{plain}");
let node = s.execute("document.node", &json!({"id": a})).unwrap();
assert_eq!(node["pixel_aligned"], true, "{node}");
}
/// A copy of a pixel-aligned path that Duplicate or Paste moves by a fractional offset is snapped
/// to the pixel grid and keeps the property, and so is a pixel-aligned object slice that moves.
#[test]
fn duplicates_and_pastes_keep_it_aligned() {
let mut s = new_doc();
let id = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0);
s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap();
s.execute("edit.duplicate", &json!({"dx": 100.3, "dy": 0.4})).unwrap();
let copy = s.doc().unwrap().selection.objects[0].0;
assert_ne!(copy, id);
assert!(aligned(&s, copy));
assert_eq!(points(&s, copy), [(110.0, 21.0), (141.0, 21.0), (141.0, 30.0), (110.0, 30.0)]);
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({"dx": 0.3, "dy": 50.4})).unwrap();
let pasted = s.doc().unwrap().selection.objects[0].0;
assert!(pasted != id && pasted != copy);
assert!(aligned(&s, pasted));
assert_eq!(points(&s, pasted), [(10.0, 71.0), (41.0, 71.0), (41.0, 80.0), (10.0, 80.0)]);
// Moving an object slice moves its object, which snaps too.
s.execute("object.slice.make", &json!({"ids": [pasted]})).unwrap();
s.execute("object.slice.move", &json!({"slices": [pasted], "dx": 0.3, "dy": 0.4})).unwrap();
assert_eq!(points(&s, pasted), [(10.0, 71.0), (41.0, 71.0), (41.0, 80.0), (10.0, 80.0)]);
}
/// Pixel-aligned art of a linked opacity mask snaps when its object moves.
#[test]
fn the_art_of_a_linked_opacity_mask_keeps_it_aligned() {
let mut s = new_doc();
let o = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0);
let m = path(&mut s, "M12 23 L39 23 L39 28 L12 28 Z", 2.0);
s.execute("object.setPixelAligned", &json!({"ids": [m]})).unwrap();
s.execute("transparency.makeOpacityMask", &json!({"ids": [o, m]})).unwrap();
let mask_points = |s: &Session| {
let n = s.doc().unwrap().doc.node(NodeId(o)).unwrap().clone();
let mask = n.extras().mask.clone().expect("an opacity mask");
assert!(mask.art.extras().pixel_aligned);
mask.art.path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect::<Vec<_>>()
};
s.execute("select.set", &json!({"ids": [o]})).unwrap();
s.execute("object.move", &json!({"dx": 0.3, "dy": 0.4})).unwrap();
assert_eq!(mask_points(&s), [(12.0, 23.0), (39.0, 23.0), (39.0, 28.0), (12.0, 28.0)]);
// The masked path inside a pixel-aligned compound path.
let other = path(&mut s, "M110 21 L141 21 L141 30 L110 30 Z", 2.0);
s.execute("select.set", &json!({"ids": [o, other]})).unwrap();
let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap();
s.execute("object.setPixelAligned", &json!({"ids": [c]})).unwrap();
s.execute("select.set", &json!({"ids": [c]})).unwrap();
s.execute("object.move", &json!({"dx": 0.3, "dy": 0.4})).unwrap();
assert!(whole(&mask_points(&s)), "{:?}", mask_points(&s));
}
/// The start point of path object `id`'s placed gradient fill.
fn gradient_start(s: &Session, id: u64) -> (f64, f64) {
let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone();
let Some(vectorcraft_color::Paint::Gradient(g)) = n.appearance.paint_at(None, true) else { panic!("no gradient fill") };
let p = g.geom.expect("a placed gradient").start;
(p.x, p.y)
}
/// A placed gradient moves with a pixel-aligned path when a stroke edit snaps it.
#[test]
fn placed_gradients_follow_the_snapped_path() {
let mut s = new_doc();
let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0);
s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}, "ids": [id]})).unwrap();
{
let n = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(NodeId(id)).unwrap();
let bounds = n.geometric_bounds().unwrap();
let Some(vectorcraft_doc::AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| i.is_fill()) else { panic!("no fill") };
let vectorcraft_color::Paint::Gradient(g) = &mut f.paint else { panic!("no gradient fill") };
g.pin(bounds);
}
s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap();
let before = gradient_start(&s, id);
let i = strokes(&s, id)[0].0;
s.execute("appearance.setItem", &json!({"ids": [id], "index": i, "weight": 1})).unwrap();
assert_eq!(points(&s, id), [(10.5, 21.5), (41.5, 21.5), (41.5, 30.5), (10.5, 30.5)]);
assert!(close(gradient_start(&s, id), (before.0 + 0.5, before.1 + 0.5)), "{:?} from {before:?}", gradient_start(&s, id));
}
/// The paint-order indices of path object `id`'s strokes, and their weights.
fn strokes(s: &Session, id: u64) -> Vec<(usize, f64)> {
let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone();
n.appearance
.items
.iter()
.enumerate()
.filter_map(|(i, it)| match it {
vectorcraft_doc::AppearanceItem::Stroke(st) => Some((i, st.width)),
_ => None,
})
.collect()
}
/// Whether every coordinate is a whole number of points plus a half (within rounding error).
fn halves(points: &[(f64, f64)]) -> bool {
points.iter().all(|(x, y)| ((x - 0.5) - (x - 0.5).round()).abs() < 1e-9 && ((y - 0.5) - (y - 0.5).round()).abs() < 1e-9)
}
/// Appearance edits that add, remove, hide, repaint or reweigh a stroke snap a pixel-aligned path
/// again: Appearance panel item edits, Remove Item, Add New Stroke, Clear Appearance, Reduce to
/// Basic Appearance, Show All Hidden Attributes, moving or copying an appearance,
/// `paint.setStroke`, a graphic style and the Eyedropper. The edges go to half
/// points while a visible, painted centered stroke has an odd weight, otherwise to whole points.
#[test]
fn appearance_edits_keep_it_aligned() {
let mut s = new_doc();
let b = path(&mut s, "M110.3 20.6 L140.7 20.6 L140.7 30.3 L110.3 30.3 Z", 2.2);
let c = path(&mut s, "M210.3 20.6 L240.7 20.6 L240.7 30.3 L210.3 30.3 Z", 1.0);
s.execute("graphicStyle.new", &json!({"id": c, "name": "One"})).unwrap();
let a = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0);
s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap();
assert!(whole(&points(&s, a)));
let top = |s: &Session| strokes(s, a).last().map(|(i, _)| *i).unwrap();
let i = top(&s);
s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "weight": 3.3})).unwrap();
assert_eq!(strokes(&s, a).last().unwrap().1, 3.0);
assert!(halves(&points(&s, a)), "weight: {:?}", points(&s, a));
// Hiding and showing the stroke again puts the path back where it was.
let shown = points(&s, a);
for _ in 0..2 {
s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "visible": false})).unwrap();
assert!(whole(&points(&s, a)), "hidden: {:?}", points(&s, a));
s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "visible": true})).unwrap();
assert_eq!(points(&s, a), shown);
}
s.execute("paint.setStroke", &json!({"ids": [a], "none": true})).unwrap();
assert!(whole(&points(&s, a)), "no paint: {:?}", points(&s, a));
s.execute("paint.setStroke", &json!({"ids": [a], "color": "#00ff00"})).unwrap();
assert_eq!(strokes(&s, a).last().unwrap().1, 1.0);
assert!(halves(&points(&s, a)), "painted: {:?}", points(&s, a));
let i = top(&s);
s.execute("appearance.removeItem", &json!({"ids": [a], "index": i})).unwrap();
assert!(strokes(&s, a).is_empty());
assert!(whole(&points(&s, a)), "removed: {:?}", points(&s, a));
s.execute("appearance.addStroke", &json!({"ids": [a]})).unwrap();
assert!(halves(&points(&s, a)), "added: {:?}", points(&s, a));
s.execute("appearance.clear", &json!({"ids": [a]})).unwrap();
assert!(whole(&points(&s, a)), "cleared: {:?}", points(&s, a));
s.execute("graphicStyle.apply", &json!({"ids": [a], "name": "One"})).unwrap();
assert!(halves(&points(&s, a)), "style: {:?}", points(&s, a));
s.execute("appearance.copyFrom", &json!({"source": b, "ids": [a]})).unwrap();
assert_eq!(strokes(&s, a).last().unwrap().1, 2.0);
assert!(whole(&points(&s, a)), "eyedropper: {:?}", points(&s, a));
// A 1 pt stroke below a 2 pt one: half points, and whole points once Reduce to Basic Appearance
// keeps only the top stroke.
let i = top(&s);
s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "weight": 1})).unwrap();
s.execute("appearance.addStroke", &json!({"ids": [a]})).unwrap();
let i = top(&s);
s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "weight": 2})).unwrap();
assert_eq!(strokes(&s, a).iter().map(|(_, w)| *w).collect::<Vec<_>>(), [1.0, 2.0]);
assert!(halves(&points(&s, a)), "two strokes: {:?}", points(&s, a));
s.execute("appearance.reduceToBasic", &json!({"ids": [a]})).unwrap();
assert_eq!(strokes(&s, a).iter().map(|(_, w)| *w).collect::<Vec<_>>(), [2.0]);
assert!(whole(&points(&s, a)), "reduced: {:?}", points(&s, a));
// Show All Hidden Attributes, and moving or copying an appearance onto or off the path.
let i = top(&s);
s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "weight": 1})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "visible": false})).unwrap();
assert!(whole(&points(&s, a)), "hidden: {:?}", points(&s, a));
s.execute("appearance.showAllHidden", &json!({"ids": [a]})).unwrap();
assert!(halves(&points(&s, a)), "show all: {:?}", points(&s, a));
s.execute("appearance.transfer", &json!({"source": b, "target": a, "copy": true})).unwrap();
assert!(whole(&points(&s, a)), "copied onto: {:?}", points(&s, a));
let i = top(&s);
s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "weight": 1})).unwrap();
assert!(halves(&points(&s, a)), "1 pt: {:?}", points(&s, a));
s.execute("appearance.transfer", &json!({"source": a, "target": c})).unwrap();
assert!(whole(&points(&s, a)), "moved off: {:?}", points(&s, a));
// A path that is not aligned keeps its coordinates through the same edits.
let before = points(&s, b);
s.execute("appearance.setItem", &json!({"ids": [b], "index": strokes(&s, b)[0].0, "weight": 3.3})).unwrap();
s.execute("appearance.addStroke", &json!({"ids": [b]})).unwrap();
assert_eq!(points(&s, b), before);
}
#[test]
fn patterns_follow_the_snap_with_transform_patterns() {
let mut s = new_doc();
let tile = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setFill", &json!({"ids": [tile], "color": "#ff0000"})).unwrap();
s.execute("select.set", &json!({"ids": [tile]})).unwrap();
s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap();
s.execute("object.pattern.done", &json!({})).unwrap();
let a = path(&mut s, "M 100 100 L 150 100 L 150 150 L 100 150 Z", 2.0);
s.execute("paint.setFill", &json!({"ids": [a], "swatch": "Dots"})).unwrap();
s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap();
let tiles = |s: &Session| {
let n = s.doc().unwrap().doc.node(NodeId(a)).unwrap().clone();
let vectorcraft_color::Paint::Pattern { xf, .. } = n.appearance.fill_paint() else { panic!("no pattern fill") };
xf.as_coeffs()
};
let (before, xf) = (points(&s, a), tiles(&s));
// The move snaps the path back to where it was, and its tiles go back with it.
s.execute("select.set", &json!({"ids": [a]})).unwrap();
s.execute("object.move", &json!({"dx": 0.3, "dy": 0.0, "patterns": true})).unwrap();
assert_eq!(points(&s, a), before);
assert!(tiles(&s).iter().zip(xf).all(|(t, x)| (t - x).abs() < 1e-9), "tiles: {:?}, before {xf:?}", tiles(&s));
// A move that the snap keeps moves the tiles as far as the path.
s.execute("object.move", &json!({"dx": 0.7, "dy": 0.0, "patterns": true})).unwrap();
assert_eq!(points(&s, a)[0], (before[0].0 + 1.0, before[0].1));
assert!((tiles(&s)[4] - (xf[4] + 1.0)).abs() < 1e-9, "tiles: {:?}, before {xf:?}", tiles(&s));
}
#[test]
fn objects_on_path_members_stay_on_the_grid() {
let mut s = new_doc();
let spine = s.execute("path.create", &json!({"d": "M 0.3 100.4 L 300.3 100.4"})).unwrap()["id"].as_u64().unwrap();
let a = path(&mut s, "M 10 10 L 30 10 L 30 30 L 10 30 Z", 2.0);
s.execute("object.objectsOnPath.attach", &json!({"path": spine, "ids": [a]})).unwrap();
assert!(!whole(&points(&s, a)), "placed: {:?}", points(&s, a));
// Placing the member on the path after the edit must not move it off the grid again.
s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap();
assert!(whole(&points(&s, a)), "aligned: {:?}", points(&s, a));
// Editing the path places the member again, and it goes back on the grid.
let anchors = json!([{"x": 0.6, "y": 120.7}, {"x": 300.6, "y": 120.7}]);
s.execute("path.setAnchors", &json!({"id": spine, "subpaths": [{"anchors": anchors, "closed": false}]})).unwrap();
assert!(whole(&points(&s, a)), "path edited: {:?}", points(&s, a));
assert!(points(&s, a).iter().all(|(_, y)| *y > 105.0), "followed the path: {:?}", points(&s, a));
}
+3
View File
@@ -6336,3 +6336,6 @@ breakText Glyphs Глифи
# 3D Box add-on
3D Box 3D кутия
3D Box Tool Инструмент за 3D кутия
# Align to Pixel Grid
Align to Pixel Grid Подравняване към пикселната мрежа
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -6386,3 +6386,6 @@ shape Open Otvoren
# 3D Box add-on
3D Box 3D kutija
3D Box Tool Alat za 3D kutiju
# Align to Pixel Grid
Align to Pixel Grid Poravnaj s mrežom piksela
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -6501,3 +6501,7 @@ breakText Glyphs Glifs
# 3D Box add-on
3D Box Caixa 3D
3D Box Tool Eina Caixa 3D
# Align to Pixel Grid
Align to Pixel Grid Alinea a la quadrícula de píxels
@msg only paths and compound paths can be aligned to the pixel grid només els traçats i els traçats compostos es poden alinear a la quadrícula de píxels
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -6518,3 +6518,7 @@ breakText Glyphs Glyfy
# 3D Box add-on
3D Box 3D kvádr
3D Box Tool Nástroj 3D kvádr
# Align to Pixel Grid
Align to Pixel Grid Zarovnat na mřížku obrazových bodů
@msg only paths and compound paths can be aligned to the pixel grid na mřížku obrazových bodů lze zarovnat jen cesty a složené cesty
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -6350,3 +6350,6 @@ breakText Glyphs Glyffer
# 3D Box add-on
3D Box 3D-boks
3D Box Tool 3D-boksværktøj
# Align to Pixel Grid
Align to Pixel Grid Juster efter pixelgitter
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -6104,3 +6104,7 @@ breakText Glyphs Glyphen
# 3D Box add-on
3D Box 3D-Quader
3D Box Tool 3D-Quader-Werkzeug
# Align to Pixel Grid
Align to Pixel Grid Am Pixelraster ausrichten
@msg only paths and compound paths can be aligned to the pixel grid nur Pfade und zusammengesetzte Pfade können am Pixelraster ausgerichtet werden
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -6470,3 +6470,7 @@ breakText Glyphs Γλύφοι
# 3D Box add-on
3D Box Κουτί 3D
3D Box Tool Εργαλείο κουτιού 3D
# Align to Pixel Grid
Align to Pixel Grid Στοίχιση στο πλέγμα εικονοστοιχείων
@msg only paths and compound paths can be aligned to the pixel grid μόνο διαδρομές και σύνθετες διαδρομές μπορούν να στοιχιστούν στο πλέγμα εικονοστοιχείων
Can't render this file because it contains an unexpected character in line 168 and column 5.
+4
View File
@@ -6460,3 +6460,7 @@ breakText Glyphs Glifos
# 3D Box add-on
3D Box Caja 3D
3D Box Tool Herramienta Caja 3D
# Align to Pixel Grid
Align to Pixel Grid Alinear con cuadrícula de píxeles
@msg only paths and compound paths can be aligned to the pixel grid solo los trazados y los trazados compuestos se pueden alinear con la cuadrícula de píxeles
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -6553,3 +6553,7 @@ breakText Glyphs Merkit
# 3D Box add-on
3D Box 3D-laatikko
3D Box Tool 3D-laatikkotyökalu
# Align to Pixel Grid
Align to Pixel Grid Tasaa pikseliruudukkoon
@msg only paths and compound paths can be aligned to the pixel grid vain polut ja yhdistelmäpolut voi tasata pikseliruudukkoon
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -6513,3 +6513,7 @@ breakText Glyphs Glyphes
# 3D Box add-on
3D Box Boîte 3D
3D Box Tool Outil Boîte 3D
# Align to Pixel Grid
Align to Pixel Grid Aligner sur la grille de pixels
@msg only paths and compound paths can be aligned to the pixel grid seuls les tracés et les tracés transparents peuvent être alignés sur la grille de pixels
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -7975,6 +7975,10 @@ swatch White, Black सफ़ेद, काला
swatch Yellow पीला
swatch Yellow Green पीला हरा
theme Appearance अपीयरेंस
# Align to Pixel Grid
Align to Pixel Grid पिक्सेल ग्रिड से संरेखित करें
@msg only paths and compound paths can be aligned to the pixel grid केवल पाथ और कंपाउंड पाथ को पिक्सेल ग्रिड से संरेखित किया जा सकता है
Start Lists From Typed Markers टाइप किए गए चिह्नों से सूचियाँ शुरू करें
Marquee Selection मार्की चयन
Touching स्पर्श करते हुए
Can't render this file because it is too large.
+4
View File
@@ -6502,3 +6502,7 @@ breakText Glyphs Glifák
# 3D Box add-on
3D Box 3D doboz
3D Box Tool 3D doboz eszköz
# Align to Pixel Grid
Align to Pixel Grid Igazítás a pixelrácshoz
@msg only paths and compound paths can be aligned to the pixel grid csak útvonalak és összetett útvonalak igazíthatók a pixelrácshoz
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -6350,3 +6350,6 @@ breakText Glyphs Glif
# 3D Box add-on
3D Box Kotak 3D
3D Box Tool Alat Kotak 3D
# Align to Pixel Grid
Align to Pixel Grid Sejajarkan ke grid piksel
Can't render this file because it contains an unexpected character in line 2270 and column 5.
+4
View File
@@ -6138,3 +6138,7 @@ breakText Glyphs Glifi
# 3D Box add-on
3D Box Scatola 3D
3D Box Tool Strumento Scatola 3D
# Align to Pixel Grid
Align to Pixel Grid Allinea alla griglia pixel
@msg only paths and compound paths can be aligned to the pixel grid solo i tracciati e i tracciati composti possono essere allineati alla griglia pixel
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -6203,3 +6203,7 @@ breakText Glyphs 字形
# 3D Box add-on
3D Box 3Dボックス
3D Box Tool 3Dボックスツール
# Align to Pixel Grid
Align to Pixel Grid ピクセルグリッドに整合
@msg only paths and compound paths can be aligned to the pixel grid ピクセルグリッドに整合できるのはパスと複合パスのみです
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -6530,3 +6530,7 @@ font-script Japanese იაპონური
# 3D Box add-on
3D Box 3D ყუთი
3D Box Tool 3D ყუთის ხელსაწყო
# Align to Pixel Grid
Align to Pixel Grid პიქსელურ ბადეზე გასწორება
@msg only paths and compound paths can be aligned to the pixel grid პიქსელურ ბადეზე მხოლოდ ტრაექტორიების და შედგენილი ტრაექტორიების გასწორებაა შესაძლებელი
Can't render this file because it is too large.
+4
View File
@@ -6099,3 +6099,7 @@ breakText Glyphs 글리프
# 3D Box add-on
3D Box 3D 상자
3D Box Tool 3D 상자 도구
# Align to Pixel Grid
Align to Pixel Grid 픽셀 격자에 정렬
@msg only paths and compound paths can be aligned to the pixel grid 패스와 컴파운드 패스만 픽셀 격자에 정렬할 수 있습니다
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -6396,3 +6396,6 @@ breakText Glyphs Glyphs
# 3D Box add-on
3D Box 3D-doos
3D Box Tool 3D-doosgereedschap
# Align to Pixel Grid
Align to Pixel Grid Uitlijnen op pixelraster
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -6373,3 +6373,7 @@ breakText Glyphs Glify
# 3D Box add-on
3D Box Bryła 3D
3D Box Tool Narzędzie Bryła 3D
# Align to Pixel Grid
Align to Pixel Grid Wyrównaj do siatki pikseli
@msg only paths and compound paths can be aligned to the pixel grid do siatki pikseli można wyrównać tylko ścieżki i ścieżki złożone
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -6591,3 +6591,7 @@ swatch Yellow Green Verde-amarelado
# 3D Box add-on
3D Box Caixa 3D
3D Box Tool Ferramenta Caixa 3D
# Align to Pixel Grid
Align to Pixel Grid Alinhar à Grade de Pixels
@msg only paths and compound paths can be aligned to the pixel grid somente caminhos e caminhos compostos podem ser alinhados à grade de pixels
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -6607,3 +6607,7 @@ shape Open Открытый
# 3D Box add-on
3D Box 3D-параллелепипед
3D Box Tool Инструмент «3D-параллелепипед»
# Align to Pixel Grid
Align to Pixel Grid Выровнять по пиксельной сетке
@msg only paths and compound paths can be aligned to the pixel grid по пиксельной сетке можно выровнять только контуры и составные контуры
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -8005,3 +8005,7 @@ digits Hindi Hindi
@msg x and y must be given together x och y måste anges tillsammans
@msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) zone behöver ett ankare: docka bredvid en panel med anchor eller i en ny dockkolumn med side (Start eller End)
@msg zoom must be positive zoom måste vara positiv
# Align to Pixel Grid
Align to Pixel Grid Justera mot pixelrutnät
@msg only paths and compound paths can be aligned to the pixel grid bara banor och sammansatta banor kan justeras mot pixelrutnätet
Can't render this file because it contains an unexpected character in line 5 and column 25.
+4
View File
@@ -6475,3 +6475,7 @@ breakText Glyphs Glifler
# 3D Box add-on
3D Box 3B Kutu
3D Box Tool 3B Kutu Aracı
# Align to Pixel Grid
Align to Pixel Grid Piksel Izgarasına Hizala
@msg only paths and compound paths can be aligned to the pixel grid piksel ızgarasına yalnızca yollar ve bileşik yollar hizalanabilir
Can't render this file because it contains an unexpected character in line 5 and column 25.
+4
View File
@@ -6118,3 +6118,7 @@ breakText Glyphs Гліфи
# 3D Box add-on
3D Box 3D-паралелепіпед
3D Box Tool Інструмент «3D-паралелепіпед»
# Align to Pixel Grid
Align to Pixel Grid Вирівняти за піксельною сіткою
@msg only paths and compound paths can be aligned to the pixel grid за піксельною сіткою можна вирівняти лише контури і складені контури
Can't render this file because it contains an unexpected character in line 182 and column 5.
+4
View File
@@ -6119,3 +6119,7 @@ breakText Glyphs Glyph
# 3D Box add-on
3D Box Hộp 3D
3D Box Tool Công cụ Hộp 3D
# Align to Pixel Grid
Align to Pixel Grid Căn theo lưới pixel
@msg only paths and compound paths can be aligned to the pixel grid chỉ đường dẫn và đường dẫn phức mới căn theo lưới pixel được
Can't render this file because it contains an unexpected character in line 204 and column 5.
+4
View File
@@ -6359,3 +6359,7 @@ breakText Glyphs 字形
# 3D Box add-on
3D Box 3D 盒子
3D Box Tool 3D 盒子工具
# Align to Pixel Grid
Align to Pixel Grid 对齐像素网格
@msg only paths and compound paths can be aligned to the pixel grid 只有路径和复合路径可以对齐像素网格
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -6156,3 +6156,7 @@ breakText Glyphs 字符
# 3D Box add-on
3D Box 3D 盒子
3D Box Tool 3D 盒子工具
# Align to Pixel Grid
Align to Pixel Grid 對齊像素格點
@msg only paths and compound paths can be aligned to the pixel grid 只有路徑和複合路徑可以對齊像素格點
Can't render this file because it contains an unexpected character in line 6 and column 47.
+30 -4
View File
@@ -1,8 +1,8 @@
//! Transform panel: reference point, X/Y/W/H with the constrain link, rotate and shear, flips,
//! live-shape properties and the Scale Corners / Scale Strokes & Effects options. While the
//! Artboard tool is in use it shows and edits the active artboard's X/Y/W/H instead (a new W or H
//! resizes every selected artboard), and with only ruler guides selected their X (vertical guides)
//! and Y (horizontal ones).
//! live-shape properties and the Scale Corners / Scale Strokes & Effects / Align to Pixel Grid
//! options. While the Artboard tool is in use it shows and edits the active artboard's X/Y/W/H
//! instead (a new W or H resizes every selected artboard), and with only ruler guides selected
//! their X (vertical guides) and Y (horizontal ones).
use egui::Ui;
use serde_json::json;
@@ -363,6 +363,32 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if widgets::check(ui, tl!("Scale Strokes & Effects"), ss, true) {
set_pref(app, "scaleStrokes", !ss);
}
pixel_grid_check(app, ui);
}
/// The selected objects' Align to Pixel Grid: on or off when they agree, `None` when they differ,
/// with whether every one can be aligned (a path that is not a guide and not inside a compound path,
/// or a compound path) and their ids. `None` with nothing selected.
fn pixel_aligned_state(app: &VectorcraftApp) -> Option<(Option<bool>, bool, Vec<u64>)> {
let st = app.session.active()?;
let nodes: Vec<&Node> = st.selection.objects.iter().filter_map(|id| st.doc.node(*id)).collect();
let first = nodes.first()?.extras().pixel_aligned;
let value = nodes.iter().all(|n| n.extras().pixel_aligned == first).then_some(first);
let in_compound = |n: &Node| st.doc.parent_of(n.id).and_then(|p| st.doc.node(p)).is_some_and(|p| matches!(p.kind, NodeKind::Compound { .. }));
let alignable = nodes.iter().all(|n| matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) && !in_compound(n));
Some((value, alignable, nodes.iter().map(|n| n.id.0).collect()))
}
/// The Align to Pixel Grid checkbox (`object.setPixelAligned`): a dash when the selected objects
/// differ, and unavailable unless every selected object can be aligned. A click turns it on for
/// them all, or off when it is on for them all.
fn pixel_grid_check(app: &mut VectorcraftApp, ui: &mut Ui) {
let (value, alignable, ids) = pixel_aligned_state(app).unwrap_or((Some(false), false, vec![]));
if widgets::check3(ui, tl!("Align to Pixel Grid"), value, alignable)
&& let Err(e) = app.run("object.setPixelAligned", json!({"ids": ids, "on": value != Some(true)}))
{
app.status(e);
}
}
/// A live rectangle's, polygon's or ellipse's properties (Transform and Properties panels, and the
@@ -503,3 +503,47 @@ fn rotate_and_shear_fields_are_named() {
let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Rotate");
let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Shear");
}
/// The Align to Pixel Grid checkbox turns the selected paths' pixel-aligned property on, shows a
/// dash when they differ (a click then turns it on for both), turns it off when it is on for all,
/// and is unavailable with type selected.
#[test]
fn align_to_pixel_grid_shows_and_sets_the_property() {
use crate::panels::tests_appearance::{click, frame_events, text_rect};
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
let a = app.run("shape.rectangle", json!({"x": 10.3, "y": 20.6, "width": 30.4, "height": 9.7})).unwrap()["id"].as_u64().unwrap();
let b = app.run("shape.rectangle", json!({"x": 60.3, "y": 20.6, "width": 30.4, "height": 9.7})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [a, b]})).unwrap();
let aligned = |app: &VectorcraftApp, id: u64| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().extras().pixel_aligned;
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let toggle = |ctx: &egui::Context, app: &mut VectorcraftApp| {
let texts = frame_events(ctx, app, vec![], show);
click(ctx, app, text_rect(&texts, "Align to Pixel Grid").center(), show);
};
assert_eq!(pixel_aligned_state(&app).map(|s| (s.0, s.1)), Some((Some(false), true)));
toggle(&ctx, &mut app);
assert!(aligned(&app, a) && aligned(&app, b));
let x0 = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(a)).unwrap().geometric_bounds().unwrap().x0;
// The default 1 pt stroke is odd and centered: the edges go to half points.
assert_eq!(x0, 10.5);
app.run("object.setPixelAligned", json!({"ids": [b], "on": false})).unwrap();
assert_eq!(pixel_aligned_state(&app).map(|s| s.0), Some(None));
toggle(&ctx, &mut app);
assert!(aligned(&app, a) && aligned(&app, b));
toggle(&ctx, &mut app);
assert!(!aligned(&app, a) && !aligned(&app, b));
let t = app.run("text.create", json!({"x": 10, "y": 100, "text": "Hi"})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [a, t]})).unwrap();
assert_eq!(pixel_aligned_state(&app).map(|s| s.1), Some(false));
toggle(&ctx, &mut app);
assert!(!aligned(&app, a));
// A path inside a compound path snaps with the compound path, so it cannot be aligned alone.
app.run("select.set", json!({"ids": [a, b]})).unwrap();
let c = app.run("object.compoundPath.make", json!({})).unwrap()["id"].as_u64().unwrap();
assert_eq!(pixel_aligned_state(&app).map(|s| s.1), Some(true));
let member = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(c)).unwrap().children().unwrap()[0].id.0;
app.run("select.set", json!({"ids": [member]})).unwrap();
assert_eq!(pixel_aligned_state(&app).map(|s| s.1), Some(false));
}
+50
View File
@@ -3429,6 +3429,56 @@ Absolute target, so it leaves objects that already have that size as they are; r
{"name":"run_command","arguments":{"command":"object.transformEach","params":{"relative":false,"scaleH":50,"scaleV":50}}}
```
## Align to Pixel Grid
`object.setPixelAligned {ids?|id?, on?}` turns the pixel-aligned property of the objects given (default: the selected
objects) on or off (`on` defaults to true), as the Transform panel's Align to Pixel Grid checkbox does (described on
the help page,
<https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/pixel-preview-mode-for-web-graphics.html>).
It returns `{ids}`, the objects whose property changed. While the property is on:
- The object's horizontal and vertical straight segments sit on whole points, or on half points when a stroke centered
on the path has an odd weight, so that the stroke's edges fall on the pixel grid. A stroke that is hidden or has no
paint does not count. Its stroke weights are whole points, at least 1.
- Every move, scale, rotation, reflection, shear, Transform Each, Align or Distribute move (of objects or of
direct-selected anchors), Transform panel change, move of direct-selected anchors or of one anchor
(`path.moveAnchor`), new anchors from `path.setAnchors`, live shape change (`object.setLiveShape`), move of a
compound path's path on its own, and artboard move or resize that carries the art (`artboard.move` with
`moveArt`, `artboard.setProps` with art, Rearrange Artboards) snaps those segments again, as do the art copies of `artboard.move` with `copy` and Duplicate
Artboards, the copies that `edit.duplicate` and the paste commands move, and `object.slice.move` of an object slice,
and so does every `stroke.set`, `stroke.weightUp` and `stroke.weightDown` edit, which also rounds the new weight. An
appearance edit that adds, removes, hides, repaints or reweighs a stroke snaps them again too:
`appearance.setItem`, `appearance.addStroke`, `appearance.removeItem`, `appearance.clear`,
`appearance.reduceToBasic`, `appearance.showAllHidden`, `appearance.transfer` (both objects), `appearance.copyFrom`
(the Eyedropper), `paint.setStroke` and `graphicStyle.apply`, in the same undo step as the edit, as is every member
of an active Global Edit that such an edit or move reaches. Every other command that changes a pixel-aligned object
(for example Object › Path › Average) snaps it again as part of its undo step. Each anchor that such a segment shares moves with its handles, so diagonal segments and curves keep their shape apart from those
anchors. A rotated shape with no horizontal or vertical segment is left as it is. Pixel-aligned art of a linked
opacity mask snaps when its object moves.
- Coordinates that differ stay at least 1 pt apart within a subpath, so a narrow shape does not collapse. A
coordinate on a whole point snaps to the half point above it and one on a half point to the whole point below it, so
hiding and showing an odd stroke returns the path to where it was. A placed gradient and the art of a linked opacity
mask move with the snapped anchors, and so do pattern tiles when the move transforms patterns. A pixel-aligned member
of an Objects on Path group snaps again after the group places it on its path.
- A live rectangle, polygon, line or pie is moved and scaled as a whole to the snapped bounds, and stays live. A full
ellipse has only curves and is left as it is. A path with Live Corners snaps the outline its corners are cut from and keeps
its Live Corners. A compound path snaps each of its paths with the compound path's stroke.
- Make Compound Path from paths or compound paths that include a pixel-aligned one gives a pixel-aligned compound path
and snaps it; its paths do not keep the property, and a path inside a compound path cannot take it alone (an error).
Release Compound Path gives each released path the property of the compound path. A path pasted into a compound
path, alone or from a pasted compound path, drops its own property and snaps with the compound path.
Turning the property on snaps the objects at once; turning it off keeps their geometry. These rules are VectorCraft's
own: only paths and compound paths can be aligned, and type, images, groups, guides and other objects are an error
that leaves the document as it was. The change is one undo step, and when every object already has the value nothing
changes and no undo step is made. The property is saved in the native file (`pixel_aligned` in `document.node`), and
files saved without it read back with it off.
```json
{"name":"run_command","arguments":{"command":"object.setPixelAligned","params":{"on":true}}}
{"name":"run_command","arguments":{"command":"object.setPixelAligned","params":{"ids":[12],"on":false}}}
```
## Live Corners
`object.setLiveShape {id?, ids?, radius?, kind?, corners?, items?}` sets the corners of any path (one undo step): a live
+1
View File
@@ -711,6 +711,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- The History panel menu's Clear History (`edit.clearHistory`) removes every step to undo and to redo and leaves the document as it is; it is not an undo step itself, and it is disabled when there is no step to clear.
- The New Document from State button at the bottom of the History panel (`edit.newDocumentFromState`) makes a new document, which becomes active, from the state the panel has selected ([public source](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/undo-edits-and-manage-design-history.html)). VectorCraft's own rules: the new document takes the next Untitled-N name, is marked unsaved, has nothing selected and has no steps to undo or redo, and the source document and its steps stay as they are.
- The Transform panel menu's Transform Object Only, Transform Pattern Only and Transform Both (`transformPanelPatterns`) choose whether the fields, flips and menu flips of the Transform panel and the Properties panel's Transform section move the object and leave its pattern tiles, move the tiles alone, or both (until a choice they follow General › Transform Pattern Tiles, as before); Use Registration Point for Symbol (`useRegistrationPointForSymbol`) measures and transforms one selected symbol instance from its symbol's registration point, where the instance's flips, rotations and shears put it ([Transform panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/transform-panel-overview.html); `panels::transform::tests::the_menu_chooses_whether_the_object_its_pattern_or_both_transform`, `use_registration_point_for_symbol_measures_an_instance_from_its_registration_point`, `use_registration_point_for_symbol_follows_a_transformed_instance`, `tests_prefs::transform_panel_patterns_defaults_to_following_the_general_preference`).
- The Transform panel's Align to Pixel Grid checkbox (`object.setPixelAligned`) gives paths and compound paths a pixel-aligned property, saved in the native file: their horizontal and vertical straight segments sit on whole points (half points under an odd centered stroke) with whole stroke weights, and every move, scale, rotation, reflection, Transform Each, Align move, Transform panel change, move of direct-selected anchors or of one anchor, move of a compound path's path on its own, artboard move, resize or copy that carries the art, Duplicate or paste that moves a copy, stroke edit and appearance edit that adds, removes, hides, repaints or reweighs a stroke snaps them again, while diagonal segments and curves keep their shape; a compound path made from an aligned path or compound path is aligned and snaps its paths with its own stroke; the box shows a dash for a mixed selection and is unavailable for type, images and groups ([Pixel-aligned property](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/pixel-preview-mode-for-web-graphics.html); `tests::pixelalign::turning_it_on_snaps_the_path_in_one_undo_step`, `it_stays_aligned_through_transforms`, `it_stays_aligned_when_its_artboard_moves`, `diagonal_segments_and_curves_keep_their_shape`, `type_and_images_cannot_be_aligned`, `the_property_is_saved_and_read_back`, `stroke_edits_keep_it_aligned`, `a_compound_path_of_aligned_paths_is_aligned`, `a_compound_path_of_an_aligned_compound_path_is_aligned`, `a_stroke_without_paint_snaps_to_whole_points`, `moving_direct_selected_anchors_keeps_it_aligned`, `moving_one_anchor_keeps_it_aligned`, `moving_a_path_of_an_aligned_compound_path_keeps_it_aligned`, `artboard_options_and_artboard_copies_keep_it_aligned`, `appearance_edits_keep_it_aligned`, `placed_gradients_follow_the_snapped_path`, `duplicates_and_pastes_keep_it_aligned`, `paths_added_to_or_moved_in_an_aligned_compound_path_keep_it_aligned`, `the_art_of_a_linked_opacity_mask_keeps_it_aligned`, `a_linked_opacity_mask_follows_a_snap`, `aligning_direct_selected_anchors_keeps_it_aligned`, `global_edit_keeps_its_members_aligned`, `a_path_with_live_corners_snaps_its_inner_edges`, `geometry_edits_keep_it_aligned`, `other_path_edits_keep_it_aligned`, `panels::transform::tests::align_to_pixel_grid_shows_and_sets_the_property`).
- Object › Transform › Reset Bounding Box squares the box of every object inside the selected ones as well: the children of groups, compound paths, clip groups, blends, envelopes, repeats and intertwines, and the art of linked opacity masks. Ungroup and the release commands then bring back boxes square to the page (VectorCraft's own behavior; `bboxrotate::reset_bounding_box_squares_the_objects_inside_a_group`, `reset_bounding_box_squares_the_turned_members_of_a_square_group`, `reset_bounding_box_then_release_clipping_mask` and the other `reset_bounding_box_then_*` tests, `reset_bounding_box_leaves_the_art_of_an_unlinked_opacity_mask`).
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8).
- The errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add Spine Point and Delete Spine Point) show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages and in Bulgarian (`blend_spine_param_errors_are_translated`).