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
+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);