Files
vectorcraft/crates/engine/src/cmd/object/pixelgrid.rs
T

408 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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)
}