Merge pull request #3066 from dotCooCoo/feat/objects-on-path

Objects on Path: attach objects to a path and spread them along it
This commit is contained in:
BFlat
2026-10-19 22:07:42 +07:00
committed by GitHub
46 changed files with 1475 additions and 4 deletions
+1
View File
@@ -167,6 +167,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-new-fill.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-new-item.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-new-stroke.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-objects-on-path.svg` | VectorCraft contributors | Original work, drawn for VectorCraft: three small squares along a curve | MIT OR Apache-2.0 | |
| `assets/icons/dc-options.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-para-center.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-para-justify-all.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18C8 6 16 6 21 14"/><rect x="3.6" y="11.6" width="3.6" height="3.6"/><rect x="10.2" y="6.7" width="3.6" height="3.6"/><rect x="16.8" y="9.2" width="3.6" height="3.6"/></svg>

After

Width:  |  Height:  |  Size: 369 B

+9
View File
@@ -36,6 +36,7 @@ pub mod marks;
mod mem;
pub mod metadata;
pub mod node;
pub mod objects_on_path;
pub mod orient;
pub mod overprint;
pub mod pattern;
@@ -1560,9 +1561,17 @@ impl Document {
{
dimension.source = None;
}
let mut renamed = std::collections::HashMap::new();
if let Some(ch) = n.children_mut() {
let old: Vec<Arc<Node>> = std::mem::take(ch);
*ch = old.iter().map(|c| Arc::new(self.reid(c))).collect();
renamed.extend(old.iter().zip(ch.iter()).map(|(o, c)| (o.id, c.id)));
}
// An Objects on Path group names its path and its objects by id: the copy names the copies.
if let Some(e) = n.extras_if_any()
&& let Some(on_path) = &mut e.objects_on_path
{
on_path.rename(&renamed);
}
n
}
+3
View File
@@ -253,6 +253,9 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
b += boxed(&e.dimension) + boxed(&e.live_paint_gaps);
b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::<Value>() + json_bytes(t));
b += e.intertwine.as_deref().map_or(0, |a| ALLOC + size_of::<PathData>() + path_bytes(a));
b += e.objects_on_path.as_deref().map_or(0, |o| {
ALLOC + size_of::<crate::objects_on_path::ObjectsOnPath>() + o.members.capacity() * size_of::<crate::objects_on_path::Member>()
});
}
b += match &n.kind {
NodeKind::Path { path, live, .. } => path_bytes(path) + boxed(live),
+16
View File
@@ -572,6 +572,9 @@ pub struct NodeExtras {
pub intertwine: Option<Box<PathData>>,
/// Live Paint group: its Gap Options (Object › Live Paint › Gap Options; `None`: the defaults).
pub live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
/// Object › Objects on Path: the group's path and the objects attached to it (see
/// [`crate::objects_on_path`]).
pub objects_on_path: Option<Box<crate::objects_on_path::ObjectsOnPath>>,
}
/// No extras: what [`Node::extras`] gives for a node without any.
@@ -590,6 +593,7 @@ static NO_EXTRAS: NodeExtras = NodeExtras {
dimension: None,
intertwine: None,
live_paint_gaps: None,
objects_on_path: None,
};
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
@@ -724,6 +728,7 @@ impl Serialize for Node {
e.dimension.is_none(),
e.intertwine.is_none(),
e.live_paint_gaps.is_none(),
e.objects_on_path.is_none(),
];
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
let mut skip = skips.into_iter();
@@ -762,6 +767,7 @@ impl Serialize for Node {
field!("dimension", &e.dimension);
field!("intertwine", &e.intertwine);
field!("live_paint_gaps", &e.live_paint_gaps);
field!("objects_on_path", &e.objects_on_path);
st.end()
}
}
@@ -821,6 +827,8 @@ struct NodeWire {
intertwine: Option<Box<PathData>>,
#[serde(default)]
live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
#[serde(default)]
objects_on_path: Option<Box<crate::objects_on_path::ObjectsOnPath>>,
}
impl From<NodeWire> for Node {
@@ -840,6 +848,7 @@ impl From<NodeWire> for Node {
dimension: w.dimension,
intertwine: w.intertwine,
live_paint_gaps: w.live_paint_gaps,
objects_on_path: w.objects_on_path,
};
Node {
id: w.id,
@@ -1025,6 +1034,7 @@ impl Node {
match &self.kind {
NodeKind::Layer { .. } => "Layer",
NodeKind::Group { clip: true, .. } => "Clip Group",
NodeKind::Group { .. } if self.extras().objects_on_path.is_some() => "Objects on Path",
NodeKind::Group { .. } => "Group",
NodeKind::Path { live: Some(l), .. } => l.label(),
NodeKind::Path { guide: true, .. } => "Guide",
@@ -1251,6 +1261,12 @@ impl Node {
{
dimension.transform(a);
}
// The frames of the objects attached to a path move with them.
if let Some(e) = self.extras_if_any()
&& let Some(on_path) = &mut e.objects_on_path
{
on_path.transform(a);
}
if !self.is_layer() {
self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a);
}
+335
View File
@@ -0,0 +1,335 @@
//! 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::{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,
}
}
/// 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));
}
}
+2
View File
@@ -47,6 +47,7 @@ pub(crate) mod menucmds;
pub(crate) mod newart;
pub mod newdoc;
mod object;
pub(crate) mod objects_on_path;
mod opacitymask;
mod overprint;
mod package;
@@ -257,6 +258,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(brushsym::specs());
v.extend(patterncmds::specs());
v.extend(intertwine::specs());
v.extend(objects_on_path::specs());
v.extend(prefscmds::specs());
v.extend(distortcmds::specs());
v.extend(paint::proxy_specs());
+334
View File
@@ -0,0 +1,334 @@
//! Object › Objects on Path (#2545): objects attached to a path and spread along it.
//!
//! `object.objectsOnPath.attach` puts the selected objects and a path in an Objects on Path group:
//! a group with a [`ObjectsOnPath`] record ([`vectorcraft_doc::objects_on_path`]), the path at its
//! bottom. Called without a path (Object › Objects on Path › Attach) it chooses the Objects on Path
//! tool, whose click on a path attaches the selected objects to that path. The objects go along the
//! path column by column from left to right, top to bottom in each column, spread evenly from its
//! first anchor and turned to follow it. `object.objectsOnPath.options` sets the group's Pivot and
//! Rotate, and `object.objectsOnPath.expand` makes it a plain group.
//!
//! After every edit, [`refresh`] puts the objects of each Objects on Path group that changed back
//! on its path: editing the path moves them, an object moved on its own goes back to its place,
//! objects added to or taken out of the group join the end of the path or leave the record, and
//! art that replaces an object at its index (Break Link to Symbol) keeps its place.
//!
//! Behavior: <https://helpx.adobe.com/illustrator/using/objects-on-path.html> (the tool and
//! Object › Objects on Path › Attach; the order on the path, the attachment point, the distribution,
//! the objects squared to the path; Pivot and Rotate; path edits realign the objects; Expand).
use std::collections::HashMap;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::objects_on_path::{CENTER, ObjectsOnPath, attachable_path, is_objects_on_path, place};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_geom::Rect;
use super::edit::{roots_of, selected_roots};
use super::*;
/// The id of the Objects on Path tool.
pub const TOOL: &str = "objectsOnPath";
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"object.objectsOnPath.attach",
"Attach",
["Object", "Objects on Path"],
None,
"{path?: id of the path to attach to (without it: choose the Objects on Path tool, whose click on a path attaches the selection to it), ids?: the objects to attach (default: the selection; the path is left out of it), pivot?: 0-8 (the reference point of each object that attaches to the path, row by row from the top left; default 4, the center), rotate?: degrees counter-clockwise from upright on the path (default 0)} put the objects and the path in an Objects on Path group: the path at the bottom, the objects spread evenly along it from its first anchor (the last one at the end of an open path) in their order on the canvas, column by column from left to right and top to bottom in each column, each turned to follow the path; editing the path moves them → {id}",
has_selection,
attach
),
cmd!(
"object.objectsOnPath.options",
"Objects on Path Options",
[],
None,
"{ids?: Objects on Path groups or objects in them (default: the selection), pivot?: 0-8 (the reference point of each object that attaches to the path, row by row from the top left; 4: the center), rotate?: degrees counter-clockwise from upright on the path} set how the objects of the groups attach → {ids}",
has_objects_on_path,
options
),
cmd!(
"object.objectsOnPath.expand",
"Expand",
["Object", "Objects on Path"],
None,
"{ids?: Objects on Path groups or objects in them (default: the selection)} make the groups plain groups of the path and the objects where they are → {ids}",
has_objects_on_path,
expand
),
]
}
// ---------- predicates ----------
/// The Objects on Path groups of `ids`: the ones among them, and the innermost one around each
/// object in one.
fn groups_of(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
let mut out = vec![];
for id in ids {
let group = d.ancestry(*id).unwrap_or_default().into_iter().rev().find(|a| d.node(*a).is_some_and(is_objects_on_path));
if let Some(g) = group
&& !out.contains(&g)
{
out.push(g);
}
}
out
}
/// The Objects on Path groups of the selection ([`groups_of`]).
fn selected_groups(s: &Session) -> Vec<NodeId> {
s.active().map(|st| groups_of(&st.doc, &st.selection.objects)).unwrap_or_default()
}
/// The groups a command acts on: those of `ids` when given, otherwise those of the selection.
fn target_groups(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
if p.get("ids").is_none_or(Value::is_null) {
return Ok(selected_groups(s));
}
let ids = checked_ids_param(s, p, "ids", cmd)?;
let groups = groups_of(&s.doc()?.doc, &ids);
if groups.is_empty() {
return Err(bad(cmd, "`ids` must name Objects on Path groups or objects in them"));
}
Ok(groups)
}
fn has_objects_on_path(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
if selected_groups(s).is_empty() { Err("select an Objects on Path group".into()) } else { Ok(()) }
}
/// The Objects on Path group around `id` (not `id` itself), if any.
fn group_around(d: &Document, id: NodeId) -> Option<NodeId> {
d.ancestry(id).unwrap_or_default().into_iter().filter(|a| *a != id).find(|a| d.node(*a).is_some_and(is_objects_on_path))
}
// ---------- Attach ----------
/// `pivot` and `rotate` from `p`, each checked.
fn pivot_rotate(p: &Value, cmd: &str, pivot: u8, rotate: f64) -> Result<(u8, f64)> {
check_numbers(p, &["pivot", "rotate"], cmd)?;
let pivot = match p.get("pivot").filter(|v| !v.is_null()) {
None => pivot,
Some(v) => v.as_u64().filter(|n| *n <= 8).map(|n| n as u8).ok_or_else(|| bad(cmd, "`pivot` must be a whole number from 0 to 8"))?,
};
let rotate = match p.get("rotate").and_then(Value::as_f64) {
None => rotate,
Some(r) if r.is_finite() => r,
Some(_) => return Err(bad(cmd, "`rotate` must be a finite number")),
};
Ok((pivot, rotate))
}
/// `ids` in their order along the path: column by column from left to right, top to bottom in each
/// column. A column is a run of objects, taken by their left edges, that each start left of the
/// right edge of the column so far. Objects without bounds go last. Symbol instances are measured
/// by their art.
fn path_order(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
let symbols = d.symbol_boxes();
let mut placed: Vec<(NodeId, Rect)> = vec![];
let mut rest = vec![];
for id in ids {
match d.node(*id).and_then(|n| n.geometric_bounds_with(&symbols)) {
Some(b) => placed.push((*id, b)),
None => rest.push(*id),
}
}
placed.sort_by(|a, b| a.1.x0.total_cmp(&b.1.x0));
let mut columns: Vec<(f64, Vec<(NodeId, Rect)>)> = vec![];
for (id, b) in placed {
match columns.last_mut() {
Some((right, column)) if b.x0 < *right => {
*right = right.max(b.x1);
column.push((id, b));
}
_ => columns.push((b.x1, vec![(id, b)])),
}
}
let mut out = vec![];
for (_, mut column) in columns {
column.sort_by(|a, b| a.1.y0.total_cmp(&b.1.y0));
out.extend(column.into_iter().map(|(id, _)| id));
}
out.extend(rest);
out
}
/// Why the path `path` can't take objects, if it can't.
fn path_refused(d: &Document, path: NodeId) -> Option<&'static str> {
let n = d.node(path)?;
if attachable_path(n).is_none() {
return Some("pick a path to attach the objects to (not a guide, a compound path or type)");
}
if group_around(d, path).is_some() {
return Some("the path is already in an Objects on Path group");
}
None
}
/// Why `n` can't be attached to a path, if it can't (the help page names graphs).
fn object_refused(d: &Document, n: &Node) -> Option<&'static str> {
if n.is_layer() {
return Some("layers can't be attached to a path: select the objects in them");
}
if group_around(d, n.id).is_some() {
return Some("an object already on a path can't be attached again");
}
let mut graph = false;
n.walk(&mut |c| graph |= c.extras().graph.is_some());
graph.then_some("graphs can't be attached to a path")
}
fn attach(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.objectsOnPath.attach";
let Some(path) = p.get("path").filter(|v| !v.is_null()) else {
let view = s.last_view;
s.select_tool(TOOL, view)?;
return Ok(json!({ "tool": TOOL }));
};
let path = checked_id(s, path, C)?;
let (pivot, rotate) = pivot_rotate(p, C, CENTER, 0.0)?;
let roots = match p.get("ids") {
Some(_) => roots_of(&s.doc()?.doc, checked_ids_param(s, p, "ids", C)?),
None => selected_roots(s)?,
};
let ids: Vec<NodeId> = roots.into_iter().filter(|id| *id != path).collect();
let order = {
let d = &s.doc()?.doc;
if let Some(why) = path_refused(d, path) {
return Err(bad(C, why));
}
if ids.is_empty() {
return Err(bad(C, "select the objects to attach to the path"));
}
for id in &ids {
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
if d.ancestry(path).unwrap_or_default().contains(id) {
return Err(bad(C, "the path can't be inside one of the objects to attach"));
}
if let Some(why) = object_refused(d, n) {
return Err(bad(C, why));
}
}
path_order(d, &ids)
};
let id = s.edit("Attach Objects on Path", |d, sel| {
let members: Vec<Node> = order.iter().filter_map(|id| d.node(*id).cloned()).collect();
let rec = ObjectsOnPath::new(path, &members.iter().collect::<Vec<_>>(), pivot, rotate);
let mut wrapped = ids.clone();
wrapped.push(path);
let wrapped = d.paint_order(wrapped);
let id = d.alloc_id();
d.wrap(&wrapped, |mut children| {
// The path at the bottom: the objects are drawn over it.
if let Some(i) = children.iter().position(|c| c.id == path) {
let p = children.remove(i);
children.insert(0, p);
}
let mut g = Node::new(id, NodeKind::Group { children, clip: false });
g.extras_mut().objects_on_path = Some(Box::new(rec));
g
})?;
place_in(d, id);
sel.set([id]);
Ok(id)
})?;
Ok(json!({ "id": id.0 }))
}
// ---------- Options ----------
fn options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.objectsOnPath.options";
let groups = target_groups(s, p, C)?;
pivot_rotate(p, C, CENTER, 0.0)?;
s.edit("Objects on Path Options", |d, _| {
for g in &groups {
let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?;
let Some(rec) = n.extras().objects_on_path.as_deref().cloned() else { continue };
let (pivot, rotate) = pivot_rotate(p, C, rec.pivot, rec.rotate)?;
n.extras_mut().objects_on_path = Some(Box::new(ObjectsOnPath { pivot, rotate, ..rec }));
}
Ok(())
})?;
Ok(json!({ "ids": groups.iter().map(|g| g.0).collect::<Vec<_>>() }))
}
// ---------- Expand ----------
fn expand(s: &mut Session, p: &Value) -> Result<Value> {
let groups = target_groups(s, p, "object.objectsOnPath.expand")?;
s.edit("Expand Objects on Path", |d, sel| {
for g in &groups {
d.node_mut(*g).ok_or(EngineError::NoNode(*g))?.extras_mut().objects_on_path = None;
}
sel.set(groups.iter().copied());
Ok(())
})?;
Ok(json!({ "ids": groups.iter().map(|g| g.0).collect::<Vec<_>>() }))
}
// ---------- refresh ----------
/// 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) {
let mut groups = vec![];
changed_groups(&before.layers, &doc.layers, &mut groups);
for id in groups.iter().rev() {
follow_replacements(before, doc, *id);
place_in(doc, *id);
}
}
/// Members of the group `id` that the edit replaced with new art at the same index keep their
/// place on the path ([`ObjectsOnPath::follow_replacements`]).
fn follow_replacements(before: &Document, doc: &mut Document, id: NodeId) {
let (Some(old), Some(new)) = (before.node(id), doc.node(id)) else { return };
let (Some(old), Some(new)) = (old.children(), new.children()) else { return };
let Some(mut rec) = doc.node(id).and_then(|n| n.extras().objects_on_path.as_deref().cloned()) else { return };
let unchanged = rec.clone();
rec.follow_replacements(old, new);
if rec != unchanged
&& let Some(n) = doc.node_mut(id)
{
n.extras_mut().objects_on_path = Some(Box::new(rec));
}
}
/// [`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 };
let mut g = n.clone();
if place(&mut g, &doc.symbol_boxes())
&& let Some(slot) = doc.node_mut(id)
{
*slot = g;
}
}
/// The Objects on Path groups among `after` and inside them that are not the same nodes as before
/// (`before`: the same list before the edit), outer ones first.
fn changed_groups(before: &[Arc<Node>], after: &[Arc<Node>], out: &mut Vec<NodeId>) {
let mut by_id: Option<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 is_objects_on_path(a) {
out.push(a.id);
}
if let Some(children) = a.children() {
changed_groups(b.and_then(|b| b.children()).map_or(&[], Vec::as_slice), children, out);
}
}
}
+6 -1
View File
@@ -1702,14 +1702,17 @@ impl Session {
let before = st.doc.clone();
let before_sel = st.selection.clone();
let doc = Arc::make_mut(&mut st.doc);
// Dimensions follow the art they measure.
// Objects attached to a path follow it, and dimensions follow the art they measure.
let result = match f(doc, &mut st.selection).and_then(|v| {
cmd::objects_on_path::refresh(&before, doc);
cmd::dimension::refresh(&before, doc)?;
Ok(v)
}) {
Ok(v) => match cmd::shaper::refresh(&before, Arc::make_mut(&mut st.doc)) {
Err(e) => Err(e),
Ok(()) => {
// Attached Shaper groups whose art was rebuilt go back on their path.
cmd::objects_on_path::refresh(&before, Arc::make_mut(&mut st.doc));
// Inline graphics in text follow their symbols.
cmd::inline::refresh(&before, Arc::make_mut(&mut st.doc));
// Text Wrap: area type follows its wrap objects; then threads re-flow.
@@ -2185,6 +2188,8 @@ mod tests_newart;
#[cfg(test)]
mod tests_newdoc;
#[cfg(test)]
mod tests_objects_on_path;
#[cfg(test)]
mod tests_objexpand;
#[cfg(test)]
mod tests_opacitymask;
+390
View File
@@ -0,0 +1,390 @@
//! Object › Objects on Path (#2545): objects attached to a path, spread along it and turned to
//! follow it, kept there while the path is edited. Driven through `Session::execute`.
use serde_json::{Value, json};
use vectorcraft_geom::{Point, Rect};
use super::tests_clip::{id_of, select};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
}
fn line(s: &mut Session, a: (f64, f64), b: (f64, f64)) -> NodeId {
id_of(&s.execute("shape.line", &json!({"x1": a.0, "y1": a.1, "x2": b.0, "y2": b.1})).unwrap())
}
fn bounds(s: &Session, id: NodeId) -> Rect {
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
}
fn center(s: &Session, id: NodeId) -> Point {
let c = bounds(s, id).center();
Point::new((c.x * 1000.0).round() / 1000.0, (c.y * 1000.0).round() / 1000.0)
}
fn attach(s: &mut Session, path: NodeId, objects: &[NodeId], extra: Value) -> NodeId {
select(s, objects);
let mut p = json!({ "path": path.0 });
if let (Some(p), Some(e)) = (p.as_object_mut(), extra.as_object()) {
p.extend(e.clone());
}
id_of(&s.execute("object.objectsOnPath.attach", &p).unwrap())
}
fn set_line(s: &mut Session, id: NodeId, a: (f64, f64), b: (f64, f64)) {
let anchors = json!([{"x": a.0, "y": a.1}, {"x": b.0, "y": b.1}]);
s.execute("path.setAnchors", &json!({"id": id.0, "subpaths": [{"anchors": anchors, "closed": false}]})).unwrap();
}
/// Three 20 pt squares, out of order on the canvas, and a 300 pt line from (50, 300) to the right.
fn squares_and_line(s: &mut Session) -> ([NodeId; 3], NodeId) {
let right = rect(s, 250.0, 20.0, 20.0, 20.0);
let left = rect(s, 10.0, 100.0, 20.0, 20.0);
let middle = rect(s, 120.0, 0.0, 20.0, 20.0);
let path = line(s, (50.0, 300.0), (350.0, 300.0));
([left, middle, right], path)
}
#[test]
fn attach_spreads_the_objects_evenly_along_an_open_path_from_left_to_right() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
let g = attach(&mut s, path, &[right, left, middle], json!({}));
let d = &s.doc().unwrap().doc;
let n = d.node(g).unwrap();
assert_eq!(n.kind_label(), "Objects on Path");
assert_eq!(n.children().unwrap().first().map(|c| c.id), Some(path), "the path is at the bottom of the group");
let rec = n.extras().objects_on_path.as_deref().unwrap();
assert_eq!(rec.members.iter().map(|m| m.id).collect::<Vec<_>>(), [left, middle, right], "left to right on the canvas");
assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [g]);
// First on the start, last on the end, evenly between: the centers on the line.
assert_eq!(center(&s, left), Point::new(50.0, 300.0));
assert_eq!(center(&s, middle), Point::new(200.0, 300.0));
assert_eq!(center(&s, right), Point::new(350.0, 300.0));
let b = bounds(&s, middle);
assert!((b.width() - 20.0).abs() < 1e-9 && (b.height() - 20.0).abs() < 1e-9, "upright on a level path: {b:?}");
}
#[test]
fn objects_in_a_column_go_top_to_bottom() {
let mut s = session();
// Its left edge is the leftmost, but it is below the other one in their column.
let lower = rect(&mut s, 8.0, 80.0, 20.0, 20.0);
let upper = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
let beside = rect(&mut s, 100.0, 0.0, 20.0, 20.0);
let path = line(&mut s, (0.0, 300.0), (300.0, 300.0));
let g = attach(&mut s, path, &[beside, lower, upper], json!({}));
let ids: Vec<NodeId> = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().unwrap().members.iter().map(|m| m.id).collect();
assert_eq!(ids, [upper, lower, beside]);
}
#[test]
fn the_objects_turn_with_the_path_and_rotate_tilts_them() {
let mut s = session();
// A 40 × 10 bar on a path that runs straight down: it turns a quarter.
let bar = rect(&mut s, 0.0, 0.0, 40.0, 10.0);
let other = rect(&mut s, 100.0, 0.0, 40.0, 10.0);
let path = line(&mut s, (200.0, 50.0), (200.0, 350.0));
let g = attach(&mut s, path, &[bar, other], json!({}));
let b = bounds(&s, bar);
assert!((b.width() - 10.0).abs() < 1e-6 && (b.height() - 40.0).abs() < 1e-6, "across the path: {b:?}");
assert_eq!(center(&s, bar), Point::new(200.0, 50.0));
// Rotate 90: turned back level, still centered on the path.
s.execute("object.objectsOnPath.options", &json!({"rotate": 90})).unwrap();
let b = bounds(&s, bar);
assert!((b.width() - 40.0).abs() < 1e-6 && (b.height() - 10.0).abs() < 1e-6, "tilted by Rotate: {b:?}");
assert_eq!(center(&s, other), Point::new(200.0, 350.0));
let rec = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap();
assert_eq!((rec.pivot, rec.rotate), (4, 90.0));
// The same angle given to Attach.
let mut t = session();
let bar = rect(&mut t, 0.0, 0.0, 40.0, 10.0);
let path = line(&mut t, (200.0, 50.0), (200.0, 350.0));
attach(&mut t, path, &[bar], json!({"rotate": 90}));
let b = bounds(&t, bar);
assert!((b.width() - 40.0).abs() < 1e-6 && (b.height() - 10.0).abs() < 1e-6, "{b:?}");
}
#[test]
fn a_turned_object_attaches_square_to_the_path_or_across_it() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 40.0, 10.0);
let b = rect(&mut s, 100.0, 0.0, 40.0, 10.0);
s.execute("object.rotate", &json!({"ids": [a.0], "angle": 30})).unwrap();
s.execute("object.rotate", &json!({"ids": [b.0], "angle": 60})).unwrap();
let path = line(&mut s, (50.0, 300.0), (350.0, 300.0));
attach(&mut s, path, &[a, b], json!({}));
let (ba, bb) = (bounds(&s, a), bounds(&s, b));
assert!((ba.width() - 40.0).abs() < 1e-6 && (ba.height() - 10.0).abs() < 1e-6, "30° rounds to level: {ba:?}");
assert!((bb.width() - 10.0).abs() < 1e-6 && (bb.height() - 40.0).abs() < 1e-6, "60° rounds to upright: {bb:?}");
}
#[test]
fn a_reflected_group_keeps_the_mirrored_tilt() {
let mut s = session();
let bar = rect(&mut s, 0.0, 0.0, 40.0, 10.0);
let other = rect(&mut s, 100.0, 0.0, 40.0, 10.0);
let path = line(&mut s, (50.0, 300.0), (350.0, 300.0));
let g = attach(&mut s, path, &[bar, other], json!({"rotate": 30}));
// The x of the bar's topmost corner, from its center: tilted counter-clockwise, it is on the right.
let top_x = |s: &Session| {
let n = s.doc().unwrap().doc.node(bar).unwrap();
let anchors = &n.path_data().unwrap().subpaths[0].anchors;
let top = anchors.iter().min_by(|a, b| a.p.y.total_cmp(&b.p.y)).unwrap().p;
top.x - bounds(s, bar).center().x
};
assert!(top_x(&s) > 1.0);
select(&mut s, &[g]);
s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap();
assert!(top_x(&s) < -1.0, "mirrored: tilted clockwise");
let rec = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap();
assert_eq!(rec.rotate, -30.0);
}
#[test]
fn scaling_the_group_out_of_proportion_keeps_the_scaled_tilt_and_scaling_back_restores_it() {
let mut s = session();
let bar = rect(&mut s, 0.0, 0.0, 40.0, 10.0);
let other = rect(&mut s, 100.0, 0.0, 40.0, 10.0);
let path = line(&mut s, (50.0, 300.0), (350.0, 300.0));
let g = attach(&mut s, path, &[bar, other], json!({"rotate": 45}));
let anchors = |s: &Session| -> Vec<(f64, f64)> {
let n = s.doc().unwrap().doc.node(bar).unwrap().clone();
n.path_data().unwrap().subpaths[0].anchors.iter().map(|a| (a.p.x, a.p.y)).collect()
};
let before = anchors(&s);
select(&mut s, &[g]);
s.execute("object.scale", &json!({"sx": 200, "sy": 100, "origin": [200, 300]})).unwrap();
// The bar's long edge was at 45°: twice as wide, it slopes at atan(1/2).
let a = anchors(&s);
let (dx, dy) = (a[1].0 - a[0].0, a[1].1 - a[0].1);
assert!((dy.abs().atan2(dx.abs()).to_degrees() - 0.5f64.atan().to_degrees()).abs() < 1e-6, "{a:?}");
s.execute("object.scale", &json!({"sx": 50, "sy": 100, "origin": [200, 300]})).unwrap();
for (p, q) in anchors(&s).iter().zip(&before) {
assert!((p.0 - q.0).abs() < 1e-6 && (p.1 - q.1).abs() < 1e-6, "{:?} against {before:?}", anchors(&s));
}
}
#[test]
fn pivot_picks_the_point_that_sits_on_the_path() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
attach(&mut s, path, &[left, middle, right], json!({"pivot": 7}));
// The middle of each square's bottom edge is on the line: the squares stand on it.
let b = bounds(&s, middle);
assert!((b.y1 - 300.0).abs() < 1e-6 && (b.center().x - 200.0).abs() < 1e-6, "{b:?}");
s.execute("object.objectsOnPath.options", &json!({"pivot": 1})).unwrap();
let b = bounds(&s, middle);
assert!((b.y0 - 300.0).abs() < 1e-6, "hanging from the line by their top edge: {b:?}");
}
#[test]
fn a_closed_path_spreads_the_objects_all_the_way_round_from_its_first_anchor() {
let mut s = session();
let ids: Vec<NodeId> = (0..4).map(|i| rect(&mut s, 10.0 + 40.0 * f64::from(i), 0.0, 10.0, 10.0)).collect();
let ring = rect(&mut s, 100.0, 100.0, 200.0, 200.0);
let first = s.doc().unwrap().doc.node(ring).unwrap().path_data().unwrap().subpaths[0].anchors[0].p;
attach(&mut s, ring, &ids, json!({}));
assert_eq!(center(&s, ids[0]), first, "the first object on the first anchor");
let mut at: Vec<(i64, i64)> = ids.iter().map(|id| center(&s, *id)).map(|p| (p.x.round() as i64, p.y.round() as i64)).collect();
at.sort();
assert_eq!(at, [(100, 100), (100, 300), (300, 100), (300, 300)], "a quarter of the way round apart");
}
#[test]
fn editing_the_path_moves_the_objects_and_undo_takes_it_back() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
attach(&mut s, path, &[left, middle, right], json!({}));
set_line(&mut s, path, (50.0, 250.0), (250.0, 250.0));
assert_eq!(center(&s, left), Point::new(50.0, 250.0));
assert_eq!(center(&s, middle), Point::new(150.0, 250.0));
assert_eq!(center(&s, right), Point::new(250.0, 250.0));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(center(&s, middle), Point::new(200.0, 300.0), "one undo step puts the path and the objects back");
// Attach itself is one step.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(center(&s, middle), Point::new(130.0, 10.0));
let d = &s.doc().unwrap().doc;
assert!(d.layers.iter().all(|l| l.children().unwrap().iter().all(|c| !vectorcraft_doc::objects_on_path::is_objects_on_path(c))));
}
#[test]
fn an_object_moved_on_its_own_goes_back_on_the_path() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
let g = attach(&mut s, path, &[left, middle, right], json!({}));
s.execute("object.move", &json!({"ids": [middle.0], "dx": 30, "dy": -40})).unwrap();
assert_eq!(center(&s, middle), Point::new(200.0, 300.0));
// The whole group moves as one.
select(&mut s, &[g]);
s.execute("object.move", &json!({"dx": 10, "dy": 20})).unwrap();
assert_eq!(center(&s, middle), Point::new(210.0, 320.0));
assert_eq!(center(&s, right), Point::new(360.0, 320.0));
}
#[test]
fn objects_moved_into_the_group_join_the_end_and_ones_moved_out_leave() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
let g = attach(&mut s, path, &[left, middle], json!({}));
s.execute("layer.move", &json!({"ids": [right.0], "target": g.0, "place": "inside"})).unwrap();
assert_eq!(center(&s, left), Point::new(50.0, 300.0));
assert_eq!(center(&s, middle), Point::new(200.0, 300.0));
assert_eq!(center(&s, right), Point::new(350.0, 300.0), "the new object goes at the end");
s.execute("layer.move", &json!({"ids": [middle.0], "target": g.0, "place": "above"})).unwrap();
let rec = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap();
assert_eq!(rec.members.iter().map(|m| m.id).collect::<Vec<_>>(), [left, right]);
assert_eq!(center(&s, right), Point::new(350.0, 300.0), "two objects stay on the ends");
}
#[test]
fn art_that_replaces_an_object_keeps_its_place_on_the_path() {
let mut s = session();
let mark = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
let first = id_of(&s.execute("symbol.new", &json!({"name": "Mark", "ids": [mark.0]})).unwrap());
let second = id_of(&s.execute("symbol.place", &json!({"name": "Mark", "x": 100, "y": 10})).unwrap());
let third = id_of(&s.execute("symbol.place", &json!({"name": "Mark", "x": 200, "y": 10})).unwrap());
let path = line(&mut s, (50.0, 300.0), (350.0, 300.0));
let g = attach(&mut s, path, &[first, second, third], json!({}));
let v = s.execute("symbol.breakLink", &json!({"ids": [first.0]})).unwrap();
let art = v["ids"].as_array().and_then(|a| a.first()).and_then(Value::as_u64).map(NodeId).unwrap();
let rec = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap();
assert_eq!(rec.members.iter().map(|m| m.id).collect::<Vec<_>>(), [art, second, third], "the art takes the instance's place");
assert_eq!(center(&s, art), Point::new(50.0, 300.0));
assert_eq!(center(&s, third), Point::new(350.0, 300.0));
}
#[test]
fn a_shaper_group_stays_on_the_path_when_a_source_changes() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
rect(&mut s, 50.0, 0.0, 100.0, 100.0);
let pts = [[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]];
let shaper = NodeId(s.execute("shaper.scribble", &json!({"points": pts})).unwrap()["id"].as_u64().unwrap());
let other = rect(&mut s, 300.0, 0.0, 20.0, 20.0);
let path = line(&mut s, (50.0, 300.0), (350.0, 300.0));
attach(&mut s, path, &[shaper, other], json!({}));
assert_eq!(center(&s, shaper), Point::new(50.0, 300.0));
select(&mut s, &[a]);
s.execute("object.setBounds", &json!({"width": 200.0, "height": 100.0, "reference": 0})).unwrap();
// The rebuilt art is wider, and its center is still on the start of the path.
assert!(bounds(&s, shaper).width() > 180.0, "{:?}", bounds(&s, shaper));
assert_eq!(center(&s, shaper), Point::new(50.0, 300.0));
}
#[test]
fn the_group_saves_and_reopens_live_and_exports_as_plain_art() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
let g = attach(&mut s, path, &[left, middle, right], json!({"rotate": 30, "pivot": 7}));
let data = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone();
let mut o = Session::new();
o.execute("document.open", &json!({"name": "objects.vectorcraft", "dataBase64": data})).unwrap();
let before = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.clone();
assert_eq!(o.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path, before);
set_line(&mut o, path, (50.0, 200.0), (350.0, 200.0));
assert!((bounds(&o, middle).center().x - bounds(&s, middle).center().x).abs() < 1e-6);
assert!(bounds(&o, middle).center().y < 210.0, "editing the reopened path moves the objects");
for format in ["svg", "pdf"] {
let data = s.execute("document.export", &json!({"format": format})).unwrap()["dataBase64"].clone();
let mut o = Session::new();
o.execute("document.open", &json!({"name": format!("objects.{format}"), "dataBase64": data})).unwrap();
let r = vectorcraft_render::Renderer::new().render(&o.doc().unwrap().doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default());
let c = bounds(&s, middle).center();
assert!(r.pixel(c.x as u32, c.y as u32 - 4)[3] > 128, "{format}: the square is drawn where it is attached");
assert!(r.pixel(130, 10)[3] < 10, "{format}: not where it was drawn");
}
}
#[test]
fn a_pasted_copy_stays_live_on_its_own_path() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
let g = attach(&mut s, path, &[left, middle, right], json!({}));
select(&mut s, &[g]);
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let copy = s.doc().unwrap().selection.objects.to_vec();
let [copy] = copy[..] else { panic!("{copy:?}") };
assert_ne!(copy, g);
let n = s.doc().unwrap().doc.node(copy).unwrap().clone();
let rec = n.extras().objects_on_path.as_deref().cloned().unwrap();
let kids: Vec<NodeId> = n.children().unwrap().iter().map(|c| c.id).collect();
assert_eq!(kids.first(), Some(&rec.path), "the copy names its own path");
assert!(rec.members.iter().all(|m| kids.contains(&m.id) && ![left, middle, right].contains(&m.id)));
let (a, b) = (s.doc().unwrap().doc.node(rec.path).unwrap().geometric_bounds().unwrap(), bounds(&s, path));
set_line(&mut s, rec.path, (a.x0, a.y0 - 50.0), (a.x1, a.y0 - 50.0));
assert!((bounds(&s, rec.members[1].id).center().y - (a.y0 - 50.0)).abs() < 1e-6, "the copy's objects follow the copy's path");
assert_eq!(bounds(&s, path), b);
assert_eq!(center(&s, middle), Point::new(200.0, 300.0), "the original stays");
}
#[test]
fn expand_leaves_a_plain_group() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
let g = attach(&mut s, path, &[left, middle, right], json!({}));
let v = s.execute("object.objectsOnPath.expand", &json!({})).unwrap();
assert_eq!(v["ids"], json!([g.0]));
let n = s.doc().unwrap().doc.node(g).unwrap().clone();
assert_eq!(n.kind_label(), "Group");
assert_eq!(n.children().unwrap().len(), 4);
set_line(&mut s, path, (50.0, 250.0), (250.0, 250.0));
assert_eq!(center(&s, middle), Point::new(200.0, 300.0), "the objects stay where they were");
assert!(s.execute("object.objectsOnPath.expand", &json!({})).is_err(), "nothing left to expand");
}
#[test]
fn attach_without_a_path_chooses_the_tool() {
let mut s = session();
let ([left, ..], _) = squares_and_line(&mut s);
select(&mut s, &[left]);
assert_eq!(s.execute("object.objectsOnPath.attach", &json!({})).unwrap()["tool"], "objectsOnPath");
assert_eq!(s.tool_id(), "objectsOnPath");
}
#[test]
fn what_cannot_be_attached_is_refused_with_a_reason() {
let mut s = session();
let ([left, _, right], path) = squares_and_line(&mut s);
let err = |s: &mut Session, p: Value| s.execute("object.objectsOnPath.attach", &p).unwrap_err().to_string();
select(&mut s, &[path]);
assert!(err(&mut s, json!({"path": path.0})).contains("select the objects"), "the path alone");
let dot = line(&mut s, (10.0, 10.0), (10.0, 10.0));
assert!(err(&mut s, json!({"path": dot.0, "ids": [left.0]})).contains("pick a path"), "a path without length");
let guide = line(&mut s, (0.0, 380.0), (300.0, 380.0));
select(&mut s, &[guide]);
s.execute("view.guides.make", &json!({})).unwrap();
assert!(err(&mut s, json!({"path": guide.0, "ids": [right.0]})).contains("pick a path"), "a guide");
let g = attach(&mut s, path, &[right], json!({}));
let other = line(&mut s, (0.0, 350.0), (300.0, 350.0));
assert!(err(&mut s, json!({"path": other.0, "ids": [right.0]})).contains("already on a path"));
assert!(err(&mut s, json!({"path": path.0, "ids": [left.0]})).contains("already in an Objects on Path group"));
assert!(err(&mut s, json!({"path": other.0, "ids": [g.0], "pivot": 9})).contains("pivot"));
assert!(s.execute("object.objectsOnPath.options", &json!({"ids": [other.0]})).is_err());
}
#[test]
fn options_and_expand_take_ids_without_a_selection() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
let g = attach(&mut s, path, &[left, middle, right], json!({}));
s.execute("select.set", &json!({"ids": []})).unwrap();
// An object in the group names the group.
let v = s.execute("object.objectsOnPath.options", &json!({"ids": [middle.0], "pivot": 7})).unwrap();
assert_eq!(v["ids"], json!([g.0]));
assert!((bounds(&s, middle).y1 - 300.0).abs() < 1e-6, "standing on the line");
let v = s.execute("object.objectsOnPath.expand", &json!({"ids": [g.0]})).unwrap();
assert_eq!(v["ids"], json!([g.0]));
assert_eq!(s.doc().unwrap().doc.node(g).unwrap().kind_label(), "Group");
assert!(s.execute("object.objectsOnPath.expand", &json!({"ids": [g.0]})).is_err(), "no longer an Objects on Path group");
}
+1 -1
View File
@@ -101,7 +101,7 @@ pub const TOOL_GROUPS: &[&[ToolInfo]] = &[
&[t("mesh", "Mesh Tool", Some("U"), "tool-mesh")],
&[t("gradient", "Gradient Tool", Some("G"), "tool-gradient")],
&[t("eyedropper", "Eyedropper Tool", Some("I"), "tool-eyedropper"), t("measure", "Measure Tool", None, "tool-measure")],
&[t("blend", "Blend Tool", Some("W"), "tool-blend")],
&[t("blend", "Blend Tool", Some("W"), "tool-blend"), t("objectsOnPath", "Objects on Path Tool", None, "tool-objects-on-path")],
&[t("dimension", "Dimension Tool", None, "tool-dimension")],
&[
t("symbolSprayer", "Symbol Sprayer Tool", Some("Shift+S"), "tool-symbol"),
+7
View File
@@ -490,6 +490,13 @@ shown_when = ["The Blend tool over an anchor point (the blend starts there)."]
hotspot = "The crosshair's centre."
look = "Crosshair with a target badge (a ring with a centre dot)."
[[cursor]]
id = "ObjectsOnPath"
group = "Width, blend, slice and shape builder"
shown_when = ["The Objects on Path tool over a path the selected objects can attach to: a click attaches them."]
hotspot = "The crosshair's center."
look = "Crosshair with a badge of small squares along a curve, as on the tool's icon."
[[cursor]]
id = "Slice"
group = "Width, blend, slice and shape builder"
+6
View File
@@ -23,6 +23,7 @@ pub mod intertwine;
pub mod meshblend;
pub mod meshedit;
pub mod mirror;
pub mod objects_on_path;
pub mod params;
pub mod pathtype;
pub mod pen;
@@ -516,6 +517,9 @@ pub enum Cursor {
BlendObject,
/// The Blend tool over an anchor point (the blend starts there): a crosshair with a target.
BlendAnchor,
/// The Objects on Path tool over a path the selected objects can attach to (a click attaches
/// them).
ObjectsOnPath,
/// Over a bracket of selected type on a path (a drag moves it): the arrow with a bracket.
PathBracket,
/// Over the type widget of selected type (a double-click converts point type to area type and
@@ -691,6 +695,7 @@ impl Cursor {
| Cursor::Shaper
| Cursor::LivePaintBucket
| Cursor::Measure
| Cursor::ObjectsOnPath
| Cursor::PrintTiling
| Cursor::SymbolSprayer
| Cursor::SymbolShifter
@@ -809,6 +814,7 @@ pub fn create(id: &str) -> Box<dyn Tool> {
.or_else(|| cropimage::create(other))
.or_else(|| cut::create(other))
.or_else(|| intertwine::create(other))
.or_else(|| objects_on_path::create(other))
.or_else(|| touchtype::create(other))
.unwrap_or_else(|| Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection")))),
}
+159
View File
@@ -0,0 +1,159 @@
//! The Objects on Path tool (#2545): a click on a path attaches the selected objects to it
//! (`object.objectsOnPath.attach`). A path they can attach to is highlighted under the pointer,
//! and the pointer shows [`Cursor::ObjectsOnPath`] over it.
//!
//! Behavior: <https://helpx.adobe.com/illustrator/using/objects-on-path.html>.
use serde_json::json;
use vectorcraft_doc::NodeId;
use vectorcraft_doc::objects_on_path::{attachable_path, is_objects_on_path};
use vectorcraft_geom::{BezPath, Point};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// The highlight of the path under the pointer.
const HIGHLIGHT: [u8; 3] = [0x4f, 0x9d, 0xff];
/// The path a click at `p` attaches the selected objects to: the path object hit there, when it can
/// take objects, is in no Objects on Path group, and something else is selected.
pub fn target(cx: &ToolContext, p: Point) -> Option<(NodeId, BezPath)> {
let h = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options())?;
let n = cx.doc.node(h.leaf)?;
let path = attachable_path(n)?;
if h.ancestry.iter().filter(|a| **a != h.leaf).any(|a| cx.doc.node(*a).is_some_and(is_objects_on_path)) {
return None;
}
cx.selection.objects.iter().any(|id| *id != h.leaf).then(|| (h.leaf, path.to_bezpath()))
}
#[derive(Default)]
pub struct ObjectsOnPathTool {
/// The path under the pointer the selection can attach to, and its outline.
hover: Option<(NodeId, BezPath)>,
/// Where the press was, while the button is down.
press: Option<Point>,
}
impl Tool for ObjectsOnPathTool {
fn id(&self) -> &'static str {
"objectsOnPath"
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
match ev.kind {
PointerKind::Move => {
self.hover = target(cx, p);
vec![]
}
PointerKind::Down => {
self.press = Some(p);
vec![]
}
PointerKind::Up => {
// A press that moved away is not a click.
let Some(start) = self.press.take().filter(|q| q.distance(p) < cx.tol(3.0)) else { return vec![] };
let Some((path, _)) = target(cx, start) else { return vec![] };
self.hover = None;
vec![Action::Exec("object.objectsOnPath.attach".into(), json!({ "path": path.0 }))]
}
_ => vec![],
}
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
self.hover.iter().map(|(_, path)| Overlay::Path { path: path.clone(), color: HIGHLIGHT, width: 2.0, dashed: false }).collect()
}
fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor {
if target(cx, p).is_some() { Cursor::ObjectsOnPath } else { Cursor::Arrow }
}
/// Escape while the button is down cancels the click: the release attaches nothing.
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
if key == ToolKey::Escape {
self.press = None;
self.hover = None;
}
vec![]
}
fn busy(&self) -> bool {
self.press.is_some()
}
}
pub fn create(id: &str) -> Option<Box<dyn crate::Tool>> {
(id == "objectsOnPath").then(|| Box::new(ObjectsOnPathTool::default()) as Box<dyn crate::Tool>)
}
#[cfg(test)]
mod tests {
use vectorcraft_doc::{Appearance, Document, Node, NodeKind, Selection};
use vectorcraft_geom::{Rect, shapes};
use super::*;
use crate::testutil::{cx, paint};
fn doc() -> (Document, NodeId, NodeId) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let line = d.alloc_id();
let square = d.alloc_id();
d.insert(Some(l), 0, Node::path(line, shapes::line(Point::new(0.0, 300.0), Point::new(400.0, 300.0)), Appearance::default_art())).unwrap();
d.insert(Some(l), 1, Node::path(square, shapes::rectangle(Rect::new(50.0, 50.0, 70.0, 70.0)), Appearance::default_art())).unwrap();
(d, line, square)
}
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
PointerEvent::new(kind, x, y)
}
#[test]
fn a_click_on_a_path_attaches_the_selection_to_it() {
let (d, line, square) = doc();
let mut sel = Selection::default();
sel.set([square]);
let paint = paint();
let cx = cx(&d, &sel, &paint);
let mut t = ObjectsOnPathTool::default();
let on = Point::new(200.0, 300.0);
assert_eq!(t.cursor(&cx, on, Mods::default()), Cursor::ObjectsOnPath);
assert_eq!(t.cursor(&cx, Point::new(200.0, 500.0), Mods::default()), Cursor::Arrow, "away from paths");
t.pointer(&cx, &ev(PointerKind::Move, on.x, on.y));
assert_eq!(t.overlays(&cx).len(), 1, "the path is highlighted");
t.pointer(&cx, &ev(PointerKind::Down, on.x, on.y));
let acts = t.pointer(&cx, &ev(PointerKind::Up, on.x, on.y));
assert!(matches!(acts.as_slice(), [Action::Exec(id, p)] if id == "object.objectsOnPath.attach" && p["path"] == line.0), "{acts:?}");
}
#[test]
fn escape_during_the_press_cancels_the_click() {
let (d, _, square) = doc();
let mut sel = Selection::default();
sel.set([square]);
let paint = paint();
let cx = cx(&d, &sel, &paint);
let mut t = ObjectsOnPathTool::default();
t.pointer(&cx, &ev(PointerKind::Move, 200.0, 300.0));
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 300.0));
assert!(t.busy());
t.key(&cx, ToolKey::Escape, Mods::default());
assert!(!t.busy() && t.overlays(&cx).is_empty());
assert!(t.pointer(&cx, &ev(PointerKind::Up, 200.0, 300.0)).is_empty(), "the release attaches nothing");
}
#[test]
fn nothing_attaches_without_other_objects_selected() {
let (d, line, _) = doc();
let mut sel = Selection::default();
sel.set([line]);
let paint = paint();
let cx = cx(&d, &sel, &paint);
let mut t = ObjectsOnPathTool::default();
assert_eq!(t.cursor(&cx, Point::new(200.0, 300.0), Mods::default()), Cursor::Arrow);
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 300.0));
assert!(t.pointer(&cx, &ev(PointerKind::Up, 200.0, 300.0)).is_empty());
assert!(matches!(d.node(line).map(|n| &n.kind), Some(NodeKind::Path { .. })));
}
}
+1
View File
@@ -680,6 +680,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
| Cursor::SymbolStainer
| Cursor::SymbolScreener
| Cursor::SymbolStyler
| Cursor::ObjectsOnPath
| Cursor::Scissors
| Cursor::ScissorsNotAllowed
| Cursor::MeshAdd
+2 -1
View File
@@ -409,7 +409,7 @@ fn magnifier(p: &mut Ink, o: Pos2, out: bool) {
/// Every cursor with a glyph: its own, drawn here, or the one it borrows.
#[cfg(test)]
const GLYPHS: [Cursor; 75] = [
const GLYPHS: [Cursor; 76] = [
Cursor::Hand,
Cursor::HandGrab,
Cursor::ZoomIn,
@@ -454,6 +454,7 @@ const GLYPHS: [Cursor; 75] = [
Cursor::Blend,
Cursor::BlendObject,
Cursor::BlendAnchor,
Cursor::ObjectsOnPath,
Cursor::PathBracket,
Cursor::TypeWidget,
Cursor::LoadedText,
+3
View File
@@ -354,6 +354,7 @@ fn heading(id: &str) -> &'static str {
_ if id.starts_with("perspective") => "Perspective",
"artboard" => "Artboards",
"slice" | "sliceSelection" => "Slices",
"objectsOnPath" => "Modify",
_ => "More",
}
}
@@ -742,6 +743,8 @@ mod tests {
assert_eq!(under("pencil"), Some("Draw"));
assert_eq!(under("shaper"), Some("Draw"), "framed with the Pencil");
assert_eq!(under("rectangle"), Some("Shapes"));
assert_eq!(under("objectsOnPath"), Some("Modify"), "framed with the Blend tool");
assert_eq!(heading("objectsOnPath"), "Modify", "though not in the Basic toolbar");
}
/// The Show section's controls (#1736): each shows (true), hides (false) or toggles, saved with
+6
View File
@@ -1372,6 +1372,12 @@
Release Blend Освобождаване на преливане
Release Envelope Освобождаване на обвивка
Release Intertwine Освобождаване на преплитане
Objects on Path Обекти по пътя
Attach Прикрепяне
Objects on Path Options Опции за обекти по пътя
Objects on Path Tool Инструмент „Обекти по пътя“
Attach Objects on Path Прикрепяне на обекти към пътя
Expand Objects on Path Разширяване на обекти по пътя
Release Live Paint Освобождаване на динамично оцветяване
Release Repeat Освобождаване на повторение
Release Slice Освобождаване на отрязък
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -1567,6 +1567,12 @@ lighting Light Světlo
Release Blend Uvolnit prolnutí
Release Envelope Uvolnit obálku
Release Intertwine Uvolnit propletení
Objects on Path Objekty na cestě
Attach Připojit
Objects on Path Options Možnosti objektů na cestě
Objects on Path Tool Nástroj Objekty na cestě
Attach Objects on Path Připojit objekty na cestu
Expand Objects on Path Rozložit objekty na cestě
Release Live Paint Uvolnit živou malbu
Release Repeat Uvolnit opakování
Release Slice Uvolnit řez
@@ -4402,6 +4408,7 @@ imageTrace Outline Obrys
@msg select a linked image: an embedded one has no file vyberte propojený obrázek: vložený nemá soubor
@msg select a repeat vyberte opakování
@msg select an Intertwine group vyberte skupinu propletení
@msg select an Objects on Path group vyberte skupinu objektů na cestě
@msg select a stroke with a variable width vyberte tah s proměnnou šířkou
@msg select a symbol instance vyberte instanci symbolu
@msg select an Image Trace object vyberte objekt vektorizace obrazu
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -5155,6 +5155,12 @@ swatch Bright Violet Kraftig violet
Release Blend Frigør blanding
Release Envelope Frigør konvolut
Release Intertwine Frigør sammenfletning
Objects on Path Objekter på kurve
Attach Tilknyt
Objects on Path Options Indstillinger for objekter på kurve
Objects on Path Tool Objekter på kurve-værktøj
Attach Objects on Path Tilknyt objekter til kurve
Expand Objects on Path Udvid objekter på kurve
Release Live Paint Frigør live-maling
Release Repeat Frigør gentagelse
Release Slice Frigør udsnit
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -1400,6 +1400,12 @@ file-filter Templates Vorlagen
Release Envelope Hülle zurückwandeln
Release Image Trace Bildnachzeichner zurückwandeln
Release Intertwine Verflechtung zurückwandeln
Objects on Path Objekte auf Pfad
Attach Anhängen
Objects on Path Options Optionen für Objekte auf Pfad
Objects on Path Tool Objekte-auf-Pfad-Werkzeug
Attach Objects on Path Objekte an Pfad anhängen
Expand Objects on Path Objekte auf Pfad umwandeln
Release Live Paint „Interaktiv malen“ zurückwandeln
Release Repeat Wiederholung zurückwandeln
Release Slice Slice zurückwandeln
@@ -4039,6 +4045,7 @@ theme Appearance Erscheinungsbild
@msg select a linked image: an embedded one has no file wählen Sie ein verknüpftes Bild aus: Ein eingebettetes hat keine Datei
@msg select a repeat wählen Sie eine Wiederholung aus
@msg select an Intertwine group wählen Sie eine Verflechtungsgruppe aus
@msg select an Objects on Path group wählen Sie eine Gruppe „Objekte auf Pfad“ aus
@msg select a stroke with a variable width wählen Sie eine Kontur mit variabler Breite aus
@msg select a symbol instance wählen Sie eine Symbolinstanz aus
@msg select an Image Trace object wählen Sie ein Bildnachzeichner-Objekt aus
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -1335,6 +1335,12 @@
Release Blend Αποδέσμευση ανάμειξης
Release Envelope Αποδέσμευση περιβλήματος
Release Intertwine Αποδέσμευση διαπλοκής
Objects on Path Αντικείμενα σε διαδρομή
Attach Προσάρτηση
Objects on Path Options Επιλογές αντικειμένων σε διαδρομή
Objects on Path Tool Αντικείμενα σε διαδρομή (Objects on Path Tool)
Attach Objects on Path Προσάρτηση αντικειμένων σε διαδρομή
Expand Objects on Path Επέκταση αντικειμένων σε διαδρομή
Release Live Paint Αποδέσμευση δυναμικής βαφής
Release Repeat Αποδέσμευση επανάληψης
Release Slice Αποδέσμευση τομής
@@ -5099,6 +5105,7 @@ effect Points Σημεία
@msg open a document with data sets ανοίξτε ένα έγγραφο με σύνολα δεδομένων
@msg nothing in data set {name} was applied: no object is bound to a variable it has a value for δεν εφαρμόστηκε τίποτα από το σύνολο δεδομένων {name}: κανένα αντικείμενο δεν είναι συνδεδεμένο με μεταβλητή για την οποία έχει τιμή
@msg select an Intertwine group επιλέξτε μια ομάδα διαπλοκής
@msg select an Objects on Path group επιλέξτε μια ομάδα αντικειμένων σε διαδρομή
@msg span must be "object" or "selection", got {v} το span πρέπει να είναι "object" ή "selection", δόθηκε {v}
@msg span: "selection" places a `gradient`, not a swatch span: το "selection" τοποθετεί ένα `gradient`, όχι δείγμα χρώματος
@msg span: "selection" places a gradient: give `gradient` span: το "selection" τοποθετεί ντεγκραντέ: δώστε `gradient`
Can't render this file because it contains an unexpected character in line 2015 and column 19.
+7
View File
@@ -1398,6 +1398,12 @@
Release Blend Soltar fusión
Release Envelope Soltar envolvente
Release Intertwine Soltar entrelazado
Objects on Path Objetos en trazado
Attach Adjuntar
Objects on Path Options Opciones de objetos en trazado
Objects on Path Tool Herramienta Objetos en trazado
Attach Objects on Path Adjuntar objetos al trazado
Expand Objects on Path Expandir objetos en trazado
Release Live Paint Soltar pintura interactiva
Release Repeat Soltar repetición
Release Slice Soltar sector
@@ -4101,6 +4107,7 @@ axis Both Ambos
@msg select a linked image: an embedded one has no file selecciona una imagen vinculada: una incrustada no tiene archivo
@msg select a repeat selecciona una repetición
@msg select an Intertwine group selecciona un grupo entrelazado
@msg select an Objects on Path group selecciona un grupo de objetos en trazado
@msg select a stroke with a variable width selecciona un trazo con anchura variable
@msg select a symbol instance selecciona una instancia de símbolo
@msg select an Image Trace object selecciona un objeto de calco de imagen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -1541,6 +1541,12 @@ lighting Light Valo
Release Blend Vapauta sulautus
Release Envelope Vapauta kuori
Release Intertwine Vapauta lomitus
Objects on Path Objektit polulla
Attach Kiinnitä
Objects on Path Options Objektit polulla -asetukset
Objects on Path Tool Objektit polulla -työkalu
Attach Objects on Path Kiinnitä objektit polulle
Expand Objects on Path Laajenna objektit polulla
Release Live Paint Vapauta elävä maalaus
Release Repeat Vapauta toisto
Release Slice Vapauta siivu
@@ -5111,6 +5117,7 @@ mark-style Japanese Japanilainen
@msg open a document with data sets avaa asiakirja, jossa on tietojoukkoja
@msg nothing in data set {name} was applied: no object is bound to a variable it has a value for tietojoukosta {name} ei käytetty mitään: mitään objektia ei ole sidottu muuttujaan, jolle siinä on arvo
@msg select an Intertwine group valitse lomitusryhmä
@msg select an Objects on Path group valitse Objektit polulla -ryhmä
@msg span must be "object" or "selection", got {v} span-arvon on oltava "object" tai "selection", saatiin {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" sijoittaa `gradient`-liukuvärin, ei värimallia
@msg span: "selection" places a gradient: give `gradient` span: "selection" sijoittaa liukuvärin: anna `gradient`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -1397,6 +1397,12 @@
Release Blend Annuler le dégradé de formes
Release Envelope Annuler l’enveloppe
Release Intertwine Annuler l’entrelacement
Objects on Path Objets sur tracé
Attach Attacher
Objects on Path Options Options d’objets sur tracé
Objects on Path Tool Outil Objets sur tracé
Attach Objects on Path Attacher des objets au tracé
Expand Objects on Path Décomposer les objets sur tracé
Release Live Paint Annuler la peinture dynamique
Release Repeat Annuler la répétition
Release Slice Annuler la tranche
@@ -4098,6 +4104,7 @@ axis Both Les deux
@msg select a linked image: an embedded one has no file sélectionnez une image liée : une image incorporée n’a pas de fichier
@msg select a repeat sélectionnez une répétition
@msg select an Intertwine group sélectionnez un groupe entrelacé
@msg select an Objects on Path group sélectionnez un groupe d’objets sur tracé
@msg select a stroke with a variable width sélectionnez un contour de largeur variable
@msg select a symbol instance sélectionnez une instance de symbole
@msg select an Image Trace object sélectionnez un objet de vectorisation de l’image
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -1771,6 +1771,12 @@ file-filter Templates Sablonok
Release Blend Összemosás megszüntetése
Release Envelope Burkoló megszüntetése
Release Intertwine Összefonás megszüntetése
Objects on Path Objektumok útvonalon
Attach Csatolás
Objects on Path Options Objektumok útvonalon beállításai
Objects on Path Tool Objektumok útvonalon
Attach Objects on Path Objektumok csatolása útvonalhoz
Expand Objects on Path Objektumok útvonalon kibontása
Release Live Paint Élő festés megszüntetése
Release Repeat Ismétlés megszüntetése
Release Slice Szelet megszüntetése
@@ -4790,6 +4796,7 @@ imageTrace Outline Körvonal
@msg select a Live Paint group jelölj ki egy élőfestés-csoportot
@msg select a repeat jelölj ki egy ismétlést
@msg select an Intertwine group jelölj ki egy összefonási csoportot
@msg select an Objects on Path group jelölj ki egy Objektumok útvonalon csoportot
@msg select a stroke with a variable width jelölj ki egy változó szélességű körvonalat
@msg select a symbol instance jelölj ki egy szimbólumpéldányt
@msg select an envelope jelölj ki egy burkolót
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -5155,6 +5155,12 @@ swatch Bright Violet Violet cerah
Release Blend Lepaskan blend
Release Envelope Lepaskan amplop
Release Intertwine Lepaskan jalinan
Objects on Path Objek pada jalur
Attach Tempelkan
Objects on Path Options Opsi objek pada jalur
Objects on Path Tool Alat objek pada jalur
Attach Objects on Path Tempelkan objek pada jalur
Expand Objects on Path Perluas objek pada jalur
Release Live Paint Lepaskan Live Paint
Release Repeat Lepaskan perulangan
Release Slice Lepaskan slice
Can't render this file because it contains an unexpected character in line 3770 and column 19.
+7
View File
@@ -1417,6 +1417,12 @@ file-filter Templates Modelli
Release Envelope Rilascia involucro
Release Image Trace Rilascia ricalco immagine
Release Intertwine Rilascia intreccio
Objects on Path Oggetti su tracciato
Attach Aggancia
Objects on Path Options Opzioni oggetti su tracciato
Objects on Path Tool Strumento Oggetti su tracciato
Attach Objects on Path Aggancia oggetti al tracciato
Expand Objects on Path Espandi oggetti su tracciato
Release Live Paint Rilascia pittura dinamica
Release Repeat Rilascia ripetizione
Release Slice Rilascia sezione
@@ -4140,6 +4146,7 @@ axis Both Entrambi
@msg select a linked image: an embedded one has no file seleziona un'immagine collegata: un'immagine incorporata non ha un file
@msg select a repeat seleziona una ripetizione
@msg select an Intertwine group seleziona un gruppo intrecciato
@msg select an Objects on Path group seleziona un gruppo di oggetti su tracciato
@msg select a stroke with a variable width seleziona una traccia con larghezza variabile
@msg select a symbol instance seleziona un'istanza di simbolo
@msg select an Image Trace object seleziona un oggetto di ricalco immagine
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -1662,6 +1662,12 @@
Release Envelope エンベロープを解除
Release Image Trace 画像トレースを解除
Release Intertwine 織り交ぜを解除
Objects on Path パス上のオブジェクト
Attach アタッチ
Objects on Path Options パス上のオブジェクトオプション
Objects on Path Tool パス上のオブジェクトツール
Attach Objects on Path パス上のオブジェクトをアタッチ
Expand Objects on Path パス上のオブジェクトを拡張
Release Live Paint ライブペイントを解除
Release Repeat リピートを解除
Release Slice スライスを解除
@@ -4023,6 +4029,7 @@ swatch Bright Violet ビビッドバイオレット
@msg pattern missing パターンが見つかりません
@msg select a repeat リピートを選択してください
@msg select an Intertwine group 織り交ぜグループを選択してください
@msg select an Objects on Path group パス上のオブジェクトグループを選択してください
@msg select the art to repeat リピートするアートを選択してください
@msg the document has no patterns ドキュメントにパターンがありません
@msg no artboard アートボードがありません
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -4830,6 +4830,7 @@ imageTrace Outline კონტური
Intertwine გადახლართვა
Arrange Intertwine Area გადახლართვის არის დალაგება
@msg select an Intertwine group მონიშნეთ გადახლართვის ჯგუფი
@msg select an Objects on Path group მონიშნეთ ტრაექტორიაზე ობიექტების ჯგუფი
Registration: საბაზისო წერტილი:
@msg span must be "object" or "selection", got {v} span უნდა იყოს "object" ან "selection", მიღებულია {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" ათავსებს `gradient`-ს და არა ფერის ნიმუშს
@@ -5015,6 +5016,12 @@ imageTrace Outline კონტური
Release Blend გადასვლის მოხსნა
Release Envelope გარსის მოხსნა
Release Intertwine გადახლართვის მოხსნა
Objects on Path ობიექტები ტრაექტორიაზე
Attach მიმაგრება
Objects on Path Options ტრაექტორიაზე ობიექტების პარამეტრები
Objects on Path Tool ტრაექტორიაზე ობიექტების ხელსაწყო
Attach Objects on Path ობიექტების ტრაექტორიაზე მიმაგრება
Expand Objects on Path ტრაექტორიაზე ობიექტების გაშლა
Release Live Paint ცოცხალი შეღებვის მოხსნა
Release Repeat გამეორების მოხსნა
Release Slice ნაჭრის მოხსნა
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -1657,6 +1657,12 @@
Release Envelope 엔벨로프 해제
Release Image Trace 이미지 추적 해제
Release Intertwine 교차 해제
Objects on Path 패스 위 개체
Attach 부착
Objects on Path Options 패스 위 개체 옵션
Objects on Path Tool 패스 위 개체 도구
Attach Objects on Path 패스 위 개체 부착
Expand Objects on Path 패스 위 개체 확장
Release Live Paint 라이브 페인트 해제
Release Repeat 반복 해제
Release Slice 분할 해제
@@ -3955,6 +3961,7 @@ theme Appearance 외관
@msg pattern missing 패턴이 없습니다
@msg select a repeat 반복을 선택하세요
@msg select an Intertwine group 교차 그룹을 선택하세요
@msg select an Objects on Path group 패스 위 개체 그룹을 선택하세요
@msg select the art to repeat 반복할 아트웍을 선택하세요
@msg the document has no patterns 문서에 패턴이 없습니다
@msg no artboard 아트보드가 없습니다
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -5167,6 +5167,12 @@ swatch Bright Violet Felviolet
Release Blend Vermenging vrijgeven
Release Envelope Omvorming vrijgeven
Release Intertwine Verstrengeling vrijgeven
Objects on Path Objecten op pad
Attach Koppelen
Objects on Path Options Opties voor objecten op pad
Objects on Path Tool Objecten-op-pad-gereedschap
Attach Objects on Path Objecten aan pad koppelen
Expand Objects on Path Objecten op pad uitbreiden
Release Live Paint Live Paint vrijgeven
Release Repeat Herhaling vrijgeven
Release Slice Slice vrijgeven
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -1486,6 +1486,12 @@ lighting Light Światło
Release Envelope Uwolnij kopertę
Release Image Trace Uwolnij obrys obrazu
Release Intertwine Uwolnij przeplatanie
Objects on Path Obiekty na ścieżce
Attach Dołącz
Objects on Path Options Opcje obiektów na ścieżce
Objects on Path Tool Narzędzie Obiekty na ścieżce
Attach Objects on Path Dołącz obiekty do ścieżki
Expand Objects on Path Rozszerz obiekty na ścieżce
Release Live Paint Uwolnij aktywne malowanie
Release Repeat Uwolnij powtórzenie
Release Slice Uwolnij plasterek
@@ -4226,6 +4232,7 @@ imageTrace Outline Kontur
@msg select a linked image: an embedded one has no file zaznacz obraz połączony: osadzony nie ma pliku
@msg select a repeat zaznacz powtórzenie
@msg select an Intertwine group zaznacz grupę przeplotu
@msg select an Objects on Path group zaznacz grupę obiektów na ścieżce
@msg select a stroke with a variable width zaznacz obrys o zmiennej szerokości
@msg select a symbol instance zaznacz wystąpienie symbolu
@msg select an Image Trace object zaznacz obiekt obrysu obrazu
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -1212,6 +1212,12 @@
Release Blend Liberar Mesclagem
Release Envelope Liberar Envelope
Release Intertwine Liberar Entrelaçamento
Objects on Path Objetos no Caminho
Attach Anexar
Objects on Path Options Opções de Objetos no Caminho
Objects on Path Tool Ferramenta Objetos no Caminho
Attach Objects on Path Anexar Objetos ao Caminho
Expand Objects on Path Expandir Objetos no Caminho
Release Live Paint Liberar Pintura ao Vivo
Release Repeat Liberar Repetição
Release Slice Liberar Fatia
@@ -3682,6 +3688,7 @@ axis Both Ambos
@msg select a linked image: an embedded one has no file selecione uma imagem vinculada: uma incorporada não tem arquivo
@msg select a repeat selecione uma repetição
@msg select an Intertwine group selecione um grupo entrelaçado
@msg select an Objects on Path group selecione um grupo de objetos no caminho
@msg select a stroke with a variable width selecione um traço com largura variável
@msg select a symbol instance selecione uma instância de símbolo
@msg select an Image Trace object selecione um objeto de Vetorização de imagem
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -1415,6 +1415,12 @@ file-filter Templates Шаблоны
Release Envelope Освободить оболочку
Release Image Trace Освободить трассировку изображения
Release Intertwine Освободить переплетение
Objects on Path Объекты на контуре
Attach Прикрепить
Objects on Path Options Параметры объектов на контуре
Objects on Path Tool Инструмент «Объекты на контуре»
Attach Objects on Path Прикрепить объекты к контуру
Expand Objects on Path Разобрать объекты на контуре
Release Live Paint Освободить быструю заливку
Release Repeat Освободить повтор
Release Slice Освободить фрагмент
@@ -4136,6 +4142,7 @@ axis Both Оба
@msg select a linked image: an embedded one has no file выделите связанное изображение: у внедрённого нет файла
@msg select a repeat выделите повтор
@msg select an Intertwine group выделите группу переплетения
@msg select an Objects on Path group выделите группу объектов на контуре
@msg select a stroke with a variable width выделите обводку с переменной шириной
@msg select a symbol instance выделите экземпляр символа
@msg select an Image Trace object выделите объект трассировки изображения
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -1374,6 +1374,12 @@
Release Blend Släpp blandning
Release Envelope Släpp kuvert
Release Intertwine Släpp sammanflätning
Objects on Path Objekt på bana
Attach Fäst
Objects on Path Options Alternativ för objekt på bana
Objects on Path Tool Verktyg för objekt på bana
Attach Objects on Path Fäst objekt på bana
Expand Objects on Path Expandera objekt på bana
Release Live Paint Släpp direktmålning
Release Repeat Släpp upprepning
Release Slice Släpp skiva
@@ -5103,6 +5109,7 @@ theme Appearance Utseende
@msg open a document with data sets öppna ett dokument med datauppsättningar
@msg nothing in data set {name} was applied: no object is bound to a variable it has a value for inget i datauppsättningen {name} användes: inget objekt är bundet till en variabel som den har ett värde för
@msg select an Intertwine group markera en sammanflätningsgrupp
@msg select an Objects on Path group markera en grupp med objekt på bana
@msg span must be "object" or "selection", got {v} span måste vara "object" eller "selection", fick {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" placerar en `gradient`, inte en färgruta
@msg span: "selection" places a gradient: give `gradient` span: "selection" placerar en övertoning: ange `gradient`
Can't render this file because it contains an unexpected character in line 5 and column 25.
+7
View File
@@ -1411,6 +1411,12 @@ file-filter Templates Шаблони
Release Envelope Звільнити оболонку
Release Image Trace Звільнити трасування зображення
Release Intertwine Звільнити переплетення
Objects on Path Об’єкти на контурі
Attach Прикріпити
Objects on Path Options Параметри об’єктів на контурі
Objects on Path Tool Інструмент «Об’єкти на контурі»
Attach Objects on Path Прикріпити об’єкти до контуру
Expand Objects on Path Розібрати об’єкти на контурі
Release Live Paint Звільнити швидку заливку
Release Repeat Звільнити повтор
Release Slice Звільнити фрагмент
@@ -4124,6 +4130,7 @@ axis Both Обидві
@msg select a linked image: an embedded one has no file виділіть пов’язане зображення: у вбудованого немає файлу
@msg select a repeat виділіть повтор
@msg select an Intertwine group виділіть групу переплетення
@msg select an Objects on Path group виділіть групу об’єктів на контурі
@msg select a stroke with a variable width виділіть обведення зі змінною шириною
@msg select a symbol instance виділіть екземпляр символу
@msg select an Image Trace object виділіть об’єкт трасування зображення
Can't render this file because it contains an unexpected character in line 725 and column 40.
+7
View File
@@ -1610,6 +1610,12 @@ file-filter Templates Mẫu
Release Envelope Giải phóng Envelope
Release Image Trace Giải phóng vector hóa ảnh
Release Intertwine Giải phóng đan xen
Objects on Path Đối tượng trên đường dẫn
Attach Gắn
Objects on Path Options Tùy chọn đối tượng trên đường dẫn
Objects on Path Tool Công cụ Đối tượng trên đường dẫn
Attach Objects on Path Gắn đối tượng lên đường dẫn
Expand Objects on Path Mở rộng đối tượng trên đường dẫn
Release Live Paint Giải phóng tô trực tiếp
Release Repeat Giải phóng lặp lại
Release Slice Giải phóng lát cắt
@@ -4440,6 +4446,7 @@ theme Appearance Giao diện
@msg select a Live Paint group hãy chọn nhóm tô trực tiếp
@msg select a repeat hãy chọn một đối tượng lặp
@msg select an Intertwine group hãy chọn một nhóm đan xen
@msg select an Objects on Path group hãy chọn một nhóm đối tượng trên đường dẫn
@msg select a stroke with a variable width hãy chọn nét có chiều rộng biến đổi
@msg select a symbol instance hãy chọn đối tượng ký hiệu
@msg select an envelope hãy chọn một Envelope
Can't render this file because it contains an unexpected character in line 3958 and column 10.
+7
View File
@@ -1354,6 +1354,12 @@ file-filter Templates 模板
Release Blend 释放混合
Release Envelope 释放封套
Release Intertwine 释放交织
Objects on Path 路径上的对象
Attach 附加
Objects on Path Options 路径上的对象选项
Objects on Path Tool 路径上的对象工具
Attach Objects on Path 附加路径上的对象
Expand Objects on Path 扩展路径上的对象
Release Live Paint 释放实时上色
Release Repeat 释放重复
Release Slice 释放切片
@@ -3774,6 +3780,7 @@ imageTrace Outline 轮廓
@msg pattern missing 缺少图案
@msg select a repeat 请选择一个重复对象
@msg select an Intertwine group 请选择一个交织组
@msg select an Objects on Path group 请选择一个路径上的对象组
@msg select the art to repeat 请选择要重复的图稿
@msg the document has no patterns 文档中没有图案
@msg no artboard 没有画板
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -1389,6 +1389,12 @@ file-filter Templates 範本
Release Envelope 釋放封套
Release Image Trace 釋放影像描圖
Release Intertwine 釋放交織
Objects on Path 路徑上的物件
Attach 附加
Objects on Path Options 路徑上的物件選項
Objects on Path Tool 路徑上的物件工具
Attach Objects on Path 附加路徑上的物件
Expand Objects on Path 展開路徑上的物件
Release Live Paint 釋放即時上色
Release Repeat 釋放重複
Release Slice 釋放切片
@@ -1783,6 +1789,7 @@ file-filter Templates 範本
@msg pattern missing 找不到圖樣
@msg select a repeat 請選取重複物件
@msg select an Intertwine group 請選取交織群組
@msg select an Objects on Path group 請選取路徑上的物件群組
@msg select the art to repeat 請選取要重複的圖稿
@msg the document has no patterns 文件中沒有圖樣
@msg no artboard 沒有工作區域
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -115,6 +115,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
("dc-new-fill", include_bytes!("../../../assets/icons/dc-new-fill.svg")),
("dc-new-item", include_bytes!("../../../assets/icons/dc-new-item.svg")),
("dc-new-stroke", include_bytes!("../../../assets/icons/dc-new-stroke.svg")),
("dc-objects-on-path", include_bytes!("../../../assets/icons/dc-objects-on-path.svg")),
("dc-options", include_bytes!("../../../assets/icons/dc-options.svg")),
("dc-para-center", include_bytes!("../../../assets/icons/dc-para-center.svg")),
("dc-para-justify-all", include_bytes!("../../../assets/icons/dc-para-justify-all.svg")),
+1
View File
@@ -84,6 +84,7 @@ pub fn tool_icon(name: &str) -> &'static str {
"tool-measure" => "dc-measure",
"tool-dimension" => "dc-dimension",
"tool-blend" => "dc-blend",
"tool-objects-on-path" => "dc-objects-on-path",
"tool-symbol" => "dc-symbol-sprayer",
"tool-graph" => "chart-column",
"tool-artboard" => "frame",
+5
View File
@@ -2572,6 +2572,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
"Intertwine",
vec![c("Make", "object.intertwine.make"), c("Edit", "object.intertwine.edit"), c("Release", "object.intertwine.release")],
),
sub("Objects on Path", vec![c("Attach", "object.objectsOnPath.attach"), c("Expand", "object.objectsOnPath.expand")]),
sub(
"Blend",
vec![
@@ -3095,6 +3096,10 @@ pub fn context_items(app: &VectorcraftApp) -> Vec<Item> {
if several && paths {
v.push(c("Make Compound Path", "object.compoundPath.make"));
}
// Chooses the Objects on Path tool, whose click on a path attaches the selection to it.
if several {
v.push(c("Objects on Path", "object.objectsOnPath.attach"));
}
if any(|k| matches!(k, NodeKind::Compound { .. })) {
v.push(c("Release Compound Path", "object.compoundPath.release"));
}
+5 -1
View File
@@ -185,10 +185,14 @@ fn the_menu_follows_the_selection() {
let (a, b) = (rect(&mut app, 0.0, 0.0), rect(&mut app, 50.0, 50.0));
app.run("select.set", json!({"ids": [a.0, b.0]})).unwrap();
let l = labels(&menus::context_items(&app));
for label in ["Group", "Join", "Make Clipping Mask", "Make Compound Path", "Bring to Front", "Move…"] {
for label in ["Group", "Join", "Make Clipping Mask", "Make Compound Path", "Objects on Path", "Bring to Front", "Move…"] {
assert!(l.contains(&label), "{label} in {l:?}");
}
assert!(!l.contains(&"Ungroup") && !l.contains(&"Isolate Selected Group"));
// Objects on Path chooses its tool, which attaches the selection to the path clicked next.
menus::invoke(&mut app, "object.objectsOnPath.attach", json!({}));
assert_eq!(app.session.tool_id(), "objectsOnPath");
app.select_tool("selection");
app.run("object.group", json!({})).unwrap();
let l = labels(&menus::context_items(&app));
assert!(l.contains(&"Ungroup") && l.contains(&"Isolate Selected Group"));
+29
View File
@@ -4274,6 +4274,35 @@ canvas as they export. `object.blend.expand` and `object.blend.release` keep the
opacity mask and appearance (Release on a group around the keys and spine when the blend has any), and
`object.expand` with `object: true` expands the blends in the selection.
## Objects on Path
`object.objectsOnPath.attach {path?, ids?, pivot?, rotate?}` attaches the selected objects (or `ids`) to the path
`path`: the objects and the path go into an Objects on Path group, with the path at its bottom. The objects go along
the path in their order on the canvas, column by column from left to right and top to bottom in each column. The first
one sits on the path's first anchor, the last one sits on the end of an open path, and the others are spread evenly
between them by length; on a closed path the gaps run all the way round. The attachment point of each object (`pivot`,
0 to 8 row by row from the top left of its bounds without strokes; default 4, the center) sits on the path, and the
object is turned to the path's direction there. An object rotated by other than a multiple of 90° is first turned to
the nearest multiple. `rotate` tilts every object by that many degrees counter-clockwise. Without `path`, the command
chooses the Objects on Path tool (`objectsOnPath`), whose click on a path sends `{path}`. As the path, the command
refuses a guide, a compound path, type, a path without length and a path already in an Objects on Path group. As
objects, it refuses graphs, layers and objects already in an Objects on Path group.
The group stays live: after every edit its objects go back on its path. Editing the path moves them, an object moved
on its own goes back to its place, an object moved into the group (`layer.move` with `place: "inside"`) joins the end
of the path, an object moved out of it leaves, and art that replaces an object (`symbol.breakLink`) keeps its place.
`object.objectsOnPath.options {ids?, pivot?, rotate?}` changes Pivot and Rotate, and
`object.objectsOnPath.expand {ids?}` makes the groups plain groups. Both act on the groups that `ids` names (Objects
on Path groups or objects in them), or on the groups of the selection when `ids` is not given. The native file keeps
the group with an `objects_on_path` record; SVG, PDF and the other exports write the path and the objects as plain
art.
```json
{"name":"run_command","arguments":{"command":"object.objectsOnPath.attach","params":{"path":30,"ids":[12,15,18]}}}
{"name":"run_command","arguments":{"command":"object.objectsOnPath.options","params":{"pivot":7,"rotate":15}}}
{"name":"run_command","arguments":{"command":"object.objectsOnPath.expand","params":{}}}
```
## Paragraph composer
`text.setFormat {ids?, composer: "singleLine"|"everyLine"}` sets how a text object's lines are broken (the Paragraph
+1
View File
@@ -656,6 +656,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Live Paint Gap Options (Object › Live Paint › Gap Options…, `livePaint.gapOptions`): with Gap Detection on, paint stops at the gaps that run from a loose end of a path to the nearest path, up to Small (3 pt), Medium (6 pt), Large (12 pt) or a custom size; Close Gaps With Paths adds an unpainted path across each gap; Preview applies the options live and draws the gaps in the Gap Preview Color. The options are saved, copied and undone with the group, and Merge keeps the bottom group's options when its Gap Detection is on, or else turns Gap Detection on with Small when another merged group has it on. When Gap Detection is off in every merged group, it stays off.
Double-clicking the Live Paint Selection tool opens its Tool Options ([Live Paint Selection tool options](https://web.archive.org/web/20250904012025/https://helpx.adobe.com/illustrator/using/selecting-objects.html)): with Select Fills off, a click selects an edge instead of a face; with Select Strokes off, Alt-click selects a face instead of an edge; Color and Width set the color and width of the highlight on the face or edge under the pointer (red and 4 px by default). The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintSelection"}`).
Window › Blend opens the Blend panel: Make, the selected blend's Spacing and Orientation (with no blend selected, the options new blends start with), Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release, each running its Object › Blend command. Make is dimmed until two or more objects are selected. Spacing and Orientation are dimmed until a blend or two or more objects are selected, and the other buttons are dimmed until a blend is selected.
Objects on Path (#2545): a click with the Objects on Path tool (in Edit Toolbar's Modify section and the Blend tool's flyout; Object › Objects on Path › Attach and the context menu with two or more objects selected choose it) attaches the selected objects to the path clicked, open or closed, in an Objects on Path group with the path at its bottom. The objects go along the path column by column from left to right, spread evenly from its first anchor (the last one sits on the end of an open path), each turned to the path at its attachment point; Pivot and Rotate set where each one attaches and how far it tilts (`object.objectsOnPath.attach`, `object.objectsOnPath.options`). Editing the path moves the objects, an object moved on its own goes back, objects moved into or out of the group in the Layers panel join or leave the path, and Object › Objects on Path › Expand leaves a plain group. The group saves and reopens live and exports as plain art. The Space, Move All, Rotate All and Select/Move widgets, Detach, the tool's options dialog and the Properties panel section are missing.
- **Paint and appearance (M3):**
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel shows a selected colour in its own model and, with nothing selected, the new-art defaults in the mode picked last, which it keeps between sessions; Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike (the built-in Generic CMYK compensates both ways, so RGB black separated to a rich black displays as black again). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).