Files
vectorcraft/crates/doc/src/objects_on_path.rs
T
dotCooCoo 6041bb3c62 Objects on Path: clipping paths and parts of blends, envelopes and repeats stay in place
object.objectsOnPath.attach took a path inside a compound path, blend,
envelope, repeat or Intertwine group, or a clipping path, as the path
and put it into the new Objects on Path group, so it was no longer a
part of its container. As objects, a blend's key path, a repeat's
source art, an envelope's content and a clipping path were put into
the new group the same way.
The Objects on Path tool highlighted a blend's key path or a clipping
path and attached the selection to it.

objects_on_path::held now finds the container that holds an object.
The command refuses such a path or object with a reason, and the tool
does not highlight it or attach to it. A member of a compound path
given as an object still attaches as its whole compound path. The four
new messages have rows in every complete-message catalog.

Docs: docs/mcp.md (Objects on Path).
2026-10-21 16:46:13 +07:00

377 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.
//! Object › Objects on Path: objects attached to a path and spread along it.
//!
//! An Objects on Path group is an ordinary group ([`NodeKind::Group`]) with an [`ObjectsOnPath`]
//! record in its extras ([`crate::node::NodeExtras::objects_on_path`]). One child is the path, and
//! the other children are the attached objects, kept where they are drawn: the group draws, hits,
//! saves and exports as a group. The record lists the objects in their order along the path, each
//! with the frame it is drawn in and the angle that frame is turned by. [`place`] spreads them
//! evenly along the path from its first anchor (the first object sits on that anchor, and the last
//! one sits on the end of an open path), puts the attachment point of each one
//! ([`ObjectsOnPath::pivot`]) on the path, and turns its frame to the path's direction there,
//! tilted by [`ObjectsOnPath::rotate`].
//!
//! Behavior: <https://helpx.adobe.com/illustrator/using/objects-on-path.html>.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use vectorcraft_geom::{Affine, PathData, Point};
use crate::blend::Spine;
use crate::symbols::SymbolBoxes;
use crate::{Document, Node, NodeId, NodeKind};
/// The center of the nine reference points.
pub const CENTER: u8 = 4;
fn center() -> u8 {
CENTER
}
/// What makes a group an Objects on Path group (see the module docs).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ObjectsOnPath {
/// The child of the group that is the path.
pub path: NodeId,
/// The attached objects, in their order along the path.
#[serde(default)]
pub members: Vec<Member>,
/// Pivot: the reference point of each object that attaches to the path, 0 to 8, row by row
/// from the top left of the object's bounds in its frame (4: the center).
#[serde(default = "center")]
pub pivot: u8,
/// Rotate: the counter-clockwise degrees each object is turned from upright on the path. An
/// upright object has its frame's x axis along the path's direction at its attachment point.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub rotate: f64,
}
/// An object attached to the path of an Objects on Path group.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(from = "MemberWire")]
pub struct Member {
pub id: NodeId,
/// The object's frame, mapped to the document: the object's art is drawn in this frame.
pub frame: Affine,
/// The angle the frame is turned by (radians, the document's axes): the path's direction at
/// the object's attachment point plus the tilt from Rotate when it was placed, or the angle of
/// the frame's x axis before then.
pub angle: f64,
}
/// A [`Member`] as read from a file: without `angle`, the angle of the frame's x axis.
#[derive(Deserialize)]
struct MemberWire {
id: NodeId,
frame: Affine,
#[serde(default)]
angle: Option<f64>,
}
impl From<MemberWire> for Member {
fn from(w: MemberWire) -> Self {
let angle = w.angle.filter(|a| a.is_finite()).unwrap_or_else(|| x_angle(w.frame));
Self { id: w.id, frame: w.frame, angle }
}
}
/// The angle of `frame`'s x axis (radians, the document's axes).
fn x_angle(frame: Affine) -> f64 {
let [a0, a1, ..] = frame.as_coeffs();
a1.atan2(a0)
}
impl Member {
/// `n` in its frame as drawn ([`upright_frame`]).
pub fn new(n: &Node) -> Self {
let frame = upright_frame(n);
Self { id: n.id, frame, angle: x_angle(frame) }
}
}
impl ObjectsOnPath {
/// A record for the objects `members` (in their order along the path) attached to `path`, each
/// in its frame as drawn ([`upright_frame`]).
pub fn new(path: NodeId, members: &[&Node], pivot: u8, rotate: f64) -> Self {
let members = members.iter().map(|n| Member::new(n)).collect();
Self { path, members, pivot: pivot.min(8), rotate: if rotate.is_finite() { rotate } else { 0.0 } }
}
/// The frames go with the group, and each frame's angle becomes the transformed path's
/// direction at the object plus the tilt, so that [`place`] keeps each object's scale, shear
/// and reflection when it spreads the objects along the transformed path again. A reflection
/// also reverses Rotate: the objects keep the mirrored tilt to the mirrored path.
pub fn transform(&mut self, a: Affine) {
let before = tilt(self.rotate);
if a.determinant() < 0.0 && self.rotate != 0.0 {
self.rotate = -self.rotate;
}
let after = tilt(self.rotate);
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
for m in &mut self.members {
m.frame = a * m.frame;
let (sin, cos) = (m.angle - before).sin_cos();
let (x, y) = (a0 * cos + a2 * sin, a1 * cos + a3 * sin);
if x.is_finite() && y.is_finite() && (x != 0.0 || y != 0.0) {
m.angle = y.atan2(x) + after;
}
}
}
/// The children given new ids (a copy of the group): the path and the objects by their new ids.
pub fn rename(&mut self, ids: &HashMap<NodeId, NodeId>) {
let to = |id: NodeId| ids.get(&id).copied().unwrap_or(id);
self.path = to(self.path);
for m in &mut self.members {
m.id = to(m.id);
}
}
/// The group's children changed from `before` to `after`: a new child at the index of a member
/// that is gone replaces it (Break Link to Symbol, for one), keeping its place on the path.
pub fn follow_replacements(&mut self, before: &[Arc<Node>], after: &[Arc<Node>]) {
let now: HashSet<NodeId> = after.iter().map(|c| c.id).collect();
let then: HashSet<NodeId> = before.iter().map(|c| c.id).collect();
let listed: HashSet<NodeId> = self.members.iter().map(|m| m.id).collect();
let mut ids = HashMap::new();
for (old, new) in before.iter().zip(after) {
if listed.contains(&old.id) && !now.contains(&old.id) && !then.contains(&new.id) && new.id != self.path {
ids.insert(old.id, new.id);
}
}
if !ids.is_empty() {
self.rename(&ids);
}
}
}
/// Is `n` an Objects on Path group?
pub fn is_objects_on_path(n: &Node) -> bool {
matches!(n.kind, NodeKind::Group { .. }) && n.extras().objects_on_path.is_some()
}
/// The path of `n` if it is a path object an Objects on Path group can attach objects to: a path
/// (not a guide) whose first subpath has some length.
pub fn attachable_path(n: &Node) -> Option<&PathData> {
match &n.kind {
NodeKind::Path { path, guide: false, .. } if Spine::new(path).is_some_and(|s| s.length() > 1e-9) => Some(path),
_ => None,
}
}
/// What keeps an object from going into an Objects on Path group, as its path or as one of its
/// objects ([`held`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Held {
/// It is inside a compound path, blend, envelope, repeat or Intertwine group, as part of its art.
Live,
/// It is the clipping path of a clip group or of a layer with a clipping mask, or inside one.
Clipping,
}
/// What keeps the object `id` of `d` where it is ([`Held`]), if anything: in an Objects on Path
/// group it would no longer be a part of the compound path, blend, envelope, repeat or Intertwine
/// group it is in, or of the clipping path it is or is in. The innermost reason counts. `None` for
/// an object that is neither a clipping path nor inside one and is in a layer directly or through
/// plain groups and clip groups, and for an id not in `d`.
pub fn held(d: &Document, id: NodeId) -> Option<Held> {
let path = d.index_path(id)?;
let mut chain: Vec<&Node> = Vec::with_capacity(path.len());
let mut nodes = &d.layers;
for &i in &path {
let n: &Node = nodes.get(i)?;
chain.push(n);
if let Some(children) = n.children() {
nodes = children;
}
}
for (depth, n) in chain.iter().enumerate().rev() {
let Some(parent) = depth.checked_sub(1).and_then(|up| chain.get(up)) else { break };
if matches!(n.kind, NodeKind::Path { clipping: true, .. }) || (parent.clips() && path.get(depth) == Some(&0)) {
return Some(Held::Clipping);
}
if matches!(
parent.kind,
NodeKind::Compound { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Intertwine { .. }
) {
return Some(Held::Live);
}
}
None
}
/// The frame `n` is drawn in when it is attached: the page's axes turned by its rotation
/// ([`Node::bbox_angle`]) less the nearest multiple of 90°, about the center of its bounds. Placed in
/// that frame, an object stands square to the path or across it.
pub fn upright_frame(n: &Node) -> Affine {
let a = n.bbox_angle;
let off = if a.is_finite() { a - (a / 90.0).round() * 90.0 } else { 0.0 };
match n.geometric_bounds() {
Some(b) if off.abs() > 1e-9 => Affine::rotate_about(-off.to_radians(), b.center()),
_ => Affine::IDENTITY,
}
}
/// Where `n` objects attach along `path` (its first subpath): the point and the direction there
/// (radians, as `atan2` gives in the document's axes). They are spread evenly by length from the
/// first anchor: on an open path the last one is at its end, on a closed path the gaps go all the way
/// around. `None` for a path without length.
pub fn stops(path: &PathData, n: usize) -> Option<Vec<(Point, f64)>> {
let spine = Spine::new(path)?;
let len = spine.length();
if !len.is_finite() || len <= 1e-9 {
return None;
}
let closed = path.subpaths.first().is_some_and(|s| s.closed);
let gaps = if closed { n } else { n.saturating_sub(1) }.max(1);
Some((0..n).map(|i| spine.at_length(len * i as f64 / gaps as f64)).collect())
}
/// The tilt Rotate gives (radians, the document's axes, where y points down): `rotate` degrees
/// counter-clockwise on the page.
fn tilt(rotate: f64) -> f64 {
-rotate.to_radians()
}
/// The frame that puts the attachment point `pivot` of `n` (drawn in `m`'s frame) at `at`, turned
/// by `angle` (radians, the document's axes) instead of `m`'s angle: the frame's own scale, shear
/// and reflection are kept. Symbol instances are measured by their art (`symbols`).
fn placed_frame(n: &Node, symbols: &SymbolBoxes, m: &Member, pivot: u8, at: Point, angle: f64) -> Option<Affine> {
let det = m.frame.determinant();
if !det.is_finite() || det.abs() < 1e-12 || !at.x.is_finite() || !at.y.is_finite() || !angle.is_finite() || !m.angle.is_finite() {
return None;
}
let mut local = n.clone();
local.transform(m.frame.inverse(), false);
let q = vectorcraft_geom::reference_point(local.geometric_bounds_with(symbols)?, usize::from(pivot.min(8)));
let [a0, a1, a2, a3, _, _] = m.frame.as_coeffs();
let shape = Affine::rotate(-m.angle) * Affine::new([a0, a1, a2, a3, 0.0, 0.0]);
Some(Affine::translate(at.to_vec2()) * Affine::rotate(angle) * shape * Affine::translate(-q.to_vec2()))
}
/// Is `a` the identity, give or take rounding?
fn negligible(a: Affine) -> bool {
let [a0, a1, a2, a3, a4, a5] = a.as_coeffs();
(a0 - 1.0).abs() < 1e-9 && a1.abs() < 1e-9 && a2.abs() < 1e-9 && (a3 - 1.0).abs() < 1e-9 && a4.abs() < 1e-6 && a5.abs() < 1e-6
}
/// Put the objects of Objects on Path group `g` on its path (see the module docs). Before that, the
/// record lets go of the objects that are no longer in the group, and children it does not list
/// (other than the path) join it at the end of the path. A group whose path is gone, or is no
/// longer a path object, becomes a plain group. Symbol instances are measured by their art
/// (`symbols`, from the document the group is in). Returns whether `g` changed.
pub fn place(g: &mut Node, symbols: &SymbolBoxes) -> bool {
let Some(mut rec) = g.extras().objects_on_path.as_deref().cloned() else { return false };
let old = rec.clone();
let NodeKind::Group { children, .. } = &mut g.kind else { return false };
let path_id = rec.path;
let Some(path) = children.iter().find(|c| c.id == path_id).filter(|c| matches!(c.kind, NodeKind::Path { .. })) else {
g.extras_mut().objects_on_path = None;
return true;
};
let path = attachable_path(path).cloned();
let at: HashMap<NodeId, usize> = children.iter().enumerate().map(|(i, c)| (c.id, i)).collect();
rec.members.retain(|m| m.id != path_id && at.contains_key(&m.id));
let mut listed: HashSet<NodeId> = rec.members.iter().map(|m| m.id).collect();
for c in children.iter() {
if c.id != path_id && listed.insert(c.id) {
rec.members.push(Member::new(c));
}
}
let mut moved = false;
if let Some(stops) = path.and_then(|p| stops(&p, rec.members.len())) {
let turn = tilt(rec.rotate);
for (m, (p, dir)) in rec.members.iter_mut().zip(stops) {
let Some(c) = at.get(&m.id).and_then(|i| children.get_mut(*i)) else { continue };
let Some(next) = placed_frame(c, symbols, m, rec.pivot, p, dir + turn) else { continue };
let delta = next * m.frame.inverse();
if !negligible(delta) {
Arc::make_mut(c).transform(delta, false);
*m = Member { id: m.id, frame: next, angle: dir + turn };
moved = true;
}
}
}
if rec != old {
g.extras_mut().objects_on_path = Some(Box::new(rec));
return true;
}
moved
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_geom::{Rect, shapes};
fn rect(id: u64, r: Rect) -> Node {
Node::path(NodeId(id), shapes::rectangle(r), Default::default())
}
#[test]
fn stops_spread_from_the_first_anchor() {
let open = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0));
let s = stops(&open, 4).unwrap();
let xs: Vec<f64> = s.iter().map(|(p, _)| p.x.round()).collect();
assert_eq!(xs, [0.0, 100.0, 200.0, 300.0], "first at the start, last at the end of an open path");
let closed = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
let s = stops(&closed, 4).unwrap();
let mut pts: Vec<(i64, i64)> = s.iter().map(|(p, _)| (p.x.round() as i64, p.y.round() as i64)).collect();
pts.sort();
assert_eq!(pts, [(0, 0), (0, 100), (100, 0), (100, 100)], "a closed path spreads them all the way round");
assert!(stops(&shapes::line(Point::ZERO, Point::ZERO), 2).is_none());
}
#[test]
fn upright_frames_round_the_rotation_to_a_right_angle() {
let mut n = rect(1, Rect::new(0.0, 0.0, 10.0, 10.0));
assert_eq!(upright_frame(&n), Affine::IDENTITY);
n.bbox_angle = 90.0;
assert_eq!(upright_frame(&n), Affine::IDENTITY);
n.bbox_angle = 30.0;
let f = upright_frame(&n);
let [a0, a1, ..] = f.as_coeffs();
assert!((a1.atan2(a0).to_degrees() + 30.0).abs() < 1e-9, "a frame turned 30° counter-clockwise");
}
#[test]
fn a_symbol_instance_is_placed_by_its_art() {
// The art is a 40 × 10 box to the right of and below the origin, not the 20 × 20 square
// around it that instances are measured by without the symbols.
let mut d = crate::Document::new(400.0, 400.0);
d.symbols.push(crate::Symbol { name: "Bar".into(), art: Arc::new(rect(9, Rect::new(0.0, 0.0, 40.0, 10.0))) });
let line = Node::path(NodeId(1), shapes::line(Point::new(100.0, 300.0), Point::new(300.0, 300.0)), Default::default());
let instance = Node::new(NodeId(2), NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::IDENTITY });
let other = rect(3, Rect::new(0.0, 0.0, 10.0, 10.0));
let members = [&instance, &other];
let mut g = Node::new(
NodeId(4),
NodeKind::Group { children: vec![Arc::new(line), Arc::new(instance.clone()), Arc::new(other.clone())], clip: false },
);
g.extras_mut().objects_on_path = Some(Box::new(ObjectsOnPath::new(NodeId(1), &members, 7, 0.0)));
let symbols = d.symbol_boxes();
assert!(place(&mut g, &symbols));
let placed = g.children().and_then(|c| c.iter().find(|c| c.id == NodeId(2)).cloned()).unwrap();
let b = placed.geometric_bounds_with(&symbols).unwrap();
assert!((b.y1 - 300.0).abs() < 1e-9 && (b.center().x - 100.0).abs() < 1e-9, "the art's bottom middle on the start: {b:?}");
}
#[test]
fn a_member_saved_without_its_angle_reads_the_angle_of_its_frame() {
let m = Member { id: NodeId(2), frame: Affine::rotate(0.5), angle: 0.5 };
let mut v = serde_json::to_value(m).unwrap();
assert_eq!(serde_json::from_value::<Member>(v.clone()).unwrap(), m);
v.as_object_mut().unwrap().remove("angle");
let back: Member = serde_json::from_value(v).unwrap();
assert!((back.angle - 0.5).abs() < 1e-12, "{back:?}");
}
#[test]
fn a_group_without_its_path_is_a_plain_group() {
let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0));
let mut g = Node::new(NodeId(3), NodeKind::Group { children: vec![Arc::new(a.clone())], clip: false });
g.extras_mut().objects_on_path = Some(Box::new(ObjectsOnPath::new(NodeId(1), &[&a], CENTER, 0.0)));
let d = crate::Document::new(100.0, 100.0);
assert!(place(&mut g, &d.symbol_boxes()));
assert!(!is_objects_on_path(&g));
}
}