From 2b29a680f30b7060d4a6308a3ba00d1739e73f0b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 2 Nov 2026 11:32:00 -0800 Subject: [PATCH] Objects on Path: the Space widget spaces the attached objects along the path With one Objects on Path group of two or more objects selected, the Selection and Objects on Path tools show a square Space widget beyond the last object, ahead along the path. Dragging it moves the last object along the path, around its bends, as far as the pointer moves along it, and the objects between follow evenly while the first one stays. With Alt (Option on macOS) the objects spread from both ends toward or away from the center of an open path, or of the first anchor of a closed one. The distance between the objects shows next to the pointer in the document's units; the drag is one undo step, and Escape cancels it. - object.objectsOnPath.options takes spacing (points, or "auto", the default even spread) and start (points from the first anchor). A spacing the path has no room for is drawn smaller and kept, so a longer path brings it back. Older files read as Auto. - Moving the group, Pivot, Rotate, path edits, copies and saving keep the Space. Scaling, rotating or shearing the group keeps the first and last objects where the transform takes them on the new path, live shapes included; a value that cannot be kept that way scales with the path's length. - The Rotate All widget follows the first object when Space moves it. Behavior: https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html and https://helpx.adobe.com/illustrator/using/objects-on-path.html. --- crates/doc/src/blend.rs | 46 ++ crates/doc/src/node/ops.rs | 22 +- crates/doc/src/node/tests.rs | 28 ++ crates/doc/src/objects_on_path.rs | 340 +++++++++++++- crates/engine/src/cmd/objects_on_path.rs | 28 +- crates/engine/src/tests/objects_on_path.rs | 88 ++++ crates/eps/src/import/ai/read/on_path.rs | 2 +- crates/tools/src/objects_on_path.rs | 519 ++++++++++++++++++++- crates/tools/src/select.rs | 27 +- crates/ui-egui/src/canvas/overlays.rs | 27 +- crates/ui-egui/src/canvas/tests.rs | 35 ++ docs/mcp.md | 22 +- docs/target-app-parity.md | 3 +- 13 files changed, 1121 insertions(+), 66 deletions(-) diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index ce20d29cd..8bca517ee 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -862,6 +862,52 @@ impl Spine { pub fn anchor_length(&self, i: usize) -> Option { self.anchors.get(i).copied() } + /// Arc length of the point of the spine nearest `p`. An open spine's end segments run on past + /// its ends as lines, so the result is below 0 before the start and above [`Spine::length`] + /// beyond the end. With `from`, the arc length of an earlier point a drag followed, each point + /// counts as farther by half its distance along the spine from there, so a pointer passing + /// near another part of the spine stays on the part it follows. Where two points count as + /// near as each other, the one earlier along the spine counts. + pub fn length_near(&self, p: Point, from: Option) -> f64 { + self.nearest(p, from).map_or(0.0, |(_, s)| s) + } + /// [`Spine::length_near`] with how near the point counts: its distance from `p`, plus half its + /// distance along the spine from `from`. `None` for a point that is not finite. + pub fn nearest(&self, p: Point, from: Option) -> Option<(f64, f64)> { + if !p.x.is_finite() || !p.y.is_finite() { + return None; + } + let last = self.pts.len().saturating_sub(2); + let mut best: Option<(f64, f64)> = None; + for (i, (w, c)) in self.pts.windows(2).zip(&self.cum).enumerate() { + let [a, b] = [w[0], w[1]]; + let d = b - a; + let l2 = d.dot(d); + if !l2.is_finite() || l2 <= 0.0 { + continue; + } + let t = (p - a).dot(d) / l2; + let t = match (self.closed, i == 0, i == last) { + (false, true, true) => t, + (false, true, false) => t.min(1.0), + (false, false, true) => t.max(0.0), + _ => t.clamp(0.0, 1.0), + }; + let s = c + t * l2.sqrt(); + let dist = p.distance(a + d * t) + from.filter(|f| f.is_finite()).map_or(0.0, |f| self.apart(s, f) / 2.0); + if dist.is_finite() && best.is_none_or(|(b, _)| dist < b) { + best = Some((dist, s)); + } + } + best + } + /// How far apart arc lengths `s` and `f` are along the spine: the shorter way round a closed + /// spine, across its first anchor. + pub fn apart(&self, s: f64, f: f64) -> f64 { + let len = self.length(); + let d = (s - f).abs(); + if self.closed && len > 0.0 { d.rem_euclid(len).min(len - d.rem_euclid(len)) } else { d } + } /// Point and tangent angle (radians) at arc-length fraction `f` (0..1). pub fn at(&self, f: f64) -> (Point, f64) { self.at_length(f.clamp(0.0, 1.0) * self.length()) diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index b5ae03e8b..755cb51c4 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -393,6 +393,12 @@ impl Node { self.transform_scaled(a, &scaling.into()); } + /// The path of the child an Objects on Path group's objects are attached to. + fn on_path_data(&self) -> Option<&PathData> { + let r = self.extras().objects_on_path.as_deref()?; + self.children()?.iter().find(|c| c.id == r.path)?.path_data() + } + fn transform_scaled(&mut self, a: Affine, sc: &Scaling) { // A dimension's measurement moves with it. if let Some(e) = self.extras_if_any() @@ -400,12 +406,8 @@ 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); - } + // The path the objects are attached to, before it is transformed. + let on_path_before = self.on_path_data().cloned(); if !self.is_layer() { self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a); } @@ -496,6 +498,14 @@ impl Node { NodeKind::Mesh(m) => m.transform(a), NodeKind::Repeat(r) => r.transform(a, *sc), } + // The frames of the objects attached to a path move with them, and the distances along the + // path follow the path as it is now (a live shape's path is regenerated). + let on_path_after = on_path_before.as_ref().and_then(|_| self.on_path_data().cloned()); + if let Some(e) = self.extras_if_any() + && let Some(on_path) = &mut e.objects_on_path + { + on_path.transform(a, on_path_before.as_ref().zip(on_path_after.as_ref())); + } if let Some(m) = self.extras_if_any().and_then(|e| e.mask.as_mut()) && m.linked { diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs index df675580e..882018e1d 100644 --- a/crates/doc/src/node/tests.rs +++ b/crates/doc/src/node/tests.rs @@ -241,6 +241,34 @@ fn uneven_scales_keep_live_corners_circular() { assert_round_corners(&n, Affine::IDENTITY, 40.0); } +/// Objects attached to a rounded rectangle that keeps its corners when scaled stay where the scale +/// takes them on the regenerated path, which is not twice as long when twice the size. +#[test] +fn objects_on_a_live_rectangle_follow_its_regenerated_path() { + use crate::objects_on_path::{CENTER, ObjectsOnPath, stops}; + let path = live_rect(100.0, 100.0, 20.0, Affine::IDENTITY); + let objects: Vec = + (2..5).map(|i| Node::path(NodeId(i), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Default::default())).collect(); + let members: Vec<&Node> = objects.iter().collect(); + let rec = ObjectsOnPath { spacing: Some(20.0), start: 10.0, ..ObjectsOnPath::new(NodeId(1), &members, CENTER, 0.0) }; + let mut children = vec![Arc::new(path)]; + children.extend(objects.iter().cloned().map(Arc::new)); + let mut g = Node::new(NodeId(9), NodeKind::Group { children, clip: false }); + g.extras_mut().objects_on_path = Some(Box::new(rec)); + let path_of = |g: &Node| g.children().and_then(|c| c.first()).and_then(|p| p.path_data().cloned()).unwrap(); + let rec_of = |g: &Node| g.extras().objects_on_path.as_deref().cloned().unwrap(); + let r = rec_of(&g); + let before = stops(&path_of(&g), 3, r.spacing, r.start).unwrap(); + let a = Affine::scale(2.0); + g.transform(a, KEEP_CORNERS); + assert_round_corners(&g.children().unwrap()[0], Affine::IDENTITY, 20.0); + let r = rec_of(&g); + let after = stops(&path_of(&g), 3, r.spacing, r.start).unwrap(); + for ((p, _), (q, _)) in before.iter().zip(&after) { + assert!((a * *p).distance(*q) < 1e-6, "{p:?} went to {q:?}: {r:?}"); + } +} + #[test] fn rotated_and_reflected_live_rectangles_scale_along_their_own_sides() { let place = Affine::translate((200.0, 100.0)) * Affine::rotate(30f64.to_radians()); diff --git a/crates/doc/src/objects_on_path.rs b/crates/doc/src/objects_on_path.rs index 779e2eced..67c9c0ee8 100644 --- a/crates/doc/src/objects_on_path.rs +++ b/crates/doc/src/objects_on_path.rs @@ -5,8 +5,9 @@ //! 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 +//! along the path ([`stops`]): evenly from its first anchor by default (the first object sits on +//! that anchor, and the last one sits on the end of an open path), or [`ObjectsOnPath::spacing`] +//! apart from [`ObjectsOnPath::start`]. It 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`]. //! @@ -45,6 +46,14 @@ pub struct ObjectsOnPath { /// 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, + /// Space: the distance along the path (points) from each object's attachment point to the + /// next one's. `None` (Auto) spreads the objects evenly ([`layout`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spacing: Option, + /// The distance along the path (points) from its first anchor to the first object's + /// attachment point. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub start: f64, } /// An object attached to the path of an Objects on Path group. @@ -95,19 +104,38 @@ impl ObjectsOnPath { /// 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 } } + Self { path, members, pivot: pivot.min(8), rotate: if rotate.is_finite() { rotate } else { 0.0 }, spacing: None, start: 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) { + /// also reverses Rotate: the objects keep the mirrored tilt to the mirrored path. With `paths`, + /// the group's path before the transform and as it is after it, the start and a Space value + /// keep the first and last objects where the transform takes them on the path; a value that + /// cannot be followed that way scales by how much the path's length changed. Without `paths`, + /// they scale by the transform's average scale (the square root of its area scale). A + /// value that would not be finite once scaled stays as it is. + pub fn transform(&mut self, a: Affine, paths: Option<(&PathData, &PathData)>) { let before = tilt(self.rotate); + let [a0, a1, a2, a3, _, _] = a.as_coeffs(); if a.determinant() < 0.0 && self.rotate != 0.0 { self.rotate = -self.rotate; } + let along = paths.and_then(|(old, new)| { + let s = path_length(new)?.0 / path_length(old)?.0; + (s.is_finite() && s > 0.0).then_some(s) + }); + let scale = along.unwrap_or_else(|| a.determinant().abs().sqrt()); + // A move leaves the distances along the path as they are. + let moves = a0 == 1.0 && a1 == 0.0 && a2 == 0.0 && a3 == 1.0; + if !moves && scale.is_finite() && scale > 0.0 { + let scaled = |v: f64| Some(v * scale).filter(|s| s.is_finite()).unwrap_or(v); + let moved = paths.and_then(|(old, new)| self.moved_along(a, old, new, scale)); + let (spacing, start) = moved.unwrap_or((None, None)); + self.spacing = self.spacing.map(|g| spacing.unwrap_or_else(|| scaled(g))); + self.start = start.unwrap_or_else(|| scaled(self.start)); + } 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(); @@ -117,6 +145,41 @@ impl ObjectsOnPath { } } } + /// The start and Space value on `new`, the path `old` after the transform `a` (regenerated, for + /// a live shape), that keep the first object and the last one where `a` takes them: each one's + /// point on `old`, mapped by `a`, measured along `new` near `scale` times its distance along + /// `old`. Either one is `None` when it cannot be followed that way: a start before or beyond the + /// ends of an open path, or a Space value of 0, one the path has no room for, or one with fewer + /// than two objects. `None` for a path without length or a start that is not finite. + fn moved_along(&self, a: Affine, old: &PathData, new: &PathData, scale: f64) -> Option<(Option, Option)> { + let (was, is) = (Spine::new(old)?, Spine::new(new)?); + let (len, closed) = path_length(old)?; + let (new_len, _) = path_length(new)?; + if !self.start.is_finite() { + return None; + } + let n = self.members.len(); + let (s0, gap) = layout(len, closed, n, self.spacing, self.start); + let follow = |s: f64| -> Option { + let (p, _) = was.at_length(s); + let t = is.nearest(a * p, Some(s * scale))?.1; + t.is_finite().then_some(if closed { wrap(t, new_len) } else { t.clamp(0.0, new_len) }) + }; + let t0 = follow(s0)?; + // On a closed path the start keeps the number of times it goes round, when that stays finite. + let start = + if closed { Some(t0 + ((self.start - s0) / len).round() * new_len).filter(|s| s.is_finite()) } else { (s0 == self.start).then_some(t0) }; + let spacing = self.spacing.filter(|g| n > 1 && g.is_finite() && *g > 0.0 && *g <= gap).and_then(|g| { + let t1 = follow(nth_length(len, closed, s0, gap, n - 1))?; + let steps = (n - 1) as f64; + // Round a closed path, the span nearest the scaled one. + let expected = steps * g * scale; + let span = if closed { wrap(t1 - t0 - expected + new_len / 2.0, new_len) - new_len / 2.0 + expected } else { t1 - t0 }; + let g = span / steps; + (g.is_finite() && g >= 0.0).then_some(g) + }); + Some((spacing, start)) + } /// 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) { let to = |id: NodeId| ids.get(&id).copied().unwrap_or(id); @@ -210,19 +273,75 @@ pub fn upright_frame(n: &Node) -> Affine { } } -/// 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> { - let spine = Spine::new(path)?; - let len = spine.length(); - if !len.is_finite() || len <= 1e-9 { - return None; - } +/// The length of `path`'s first subpath and whether it is closed. `None` for a path without +/// length. +pub fn path_length(path: &PathData) -> Option<(f64, bool)> { + let len = Spine::new(path)?.length(); 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()) + (len.is_finite() && len > 1e-9).then_some((len, closed)) +} + +/// Where the first of `n` objects attaches along a path of length `len` (closed or not), and the +/// distance from each object's attachment point to the next one's, both along the path (points), +/// for Space `spacing` (`None`: Auto) from `start`: +/// - The first object is `start` from the first anchor: on an open path no further than its end, +/// and on a closed path less than once around. +/// - Auto spreads the objects evenly from there: on an open path the last one is at its end, and on +/// a closed path the gaps go all the way around. +/// - A Space value is kept to what the path has room for, so a shorter path draws the objects +/// closer while the value stays: on an open path the last object reaches its end at most, and on +/// a closed path the last one stops at least a gap short of the first. +/// +/// A Space value that is negative or not finite counts as Auto, and a `start` that is not finite as 0. +pub fn layout(len: f64, closed: bool, n: usize, spacing: Option, start: f64) -> (f64, f64) { + let start = match (start.is_finite(), closed) { + (false, _) => 0.0, + (true, true) => wrap(start, len), + (true, false) => start.clamp(0.0, len.max(0.0)), + }; + let room = if closed { + len / n.max(1) as f64 + } else if n > 1 { + (len - start) / (n - 1) as f64 + } else { + 0.0 + }; + let room = if room.is_finite() { room.max(0.0) } else { 0.0 }; + match spacing.filter(|g| g.is_finite() && *g >= 0.0) { + Some(g) => (start, g.min(room)), + None => (start, room), + } +} + +/// `s` taken round a closed path of length `len`: 0 or more, and less than `len`. +fn wrap(s: f64, len: f64) -> f64 { + let w = s.rem_euclid(len); + if w.is_finite() && w < len { w } else { 0.0 } +} + +/// 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 go along it as [`layout`] spaces them +/// for Space `spacing` (`None`: Auto) from `start`. `None` for a path without length. +pub fn stops(path: &PathData, n: usize, spacing: Option, start: f64) -> Option> { + let spine = Spine::new(path)?; + let (len, closed) = path_length(path)?; + let (start, gap) = layout(len, closed, n, spacing, start); + Some((0..n).map(|i| spine.at_length(nth_length(len, closed, start, gap, i))).collect()) +} + +/// Where object `i` of `n` attaches along `path`, as [`stops`] places it, without the others. +pub fn stop(path: &PathData, n: usize, spacing: Option, start: f64, i: usize) -> Option<(Point, f64)> { + let spine = Spine::new(path)?; + let (len, closed) = path_length(path)?; + let (start, gap) = layout(len, closed, n, spacing, start); + (i < n).then(|| spine.at_length(nth_length(len, closed, start, gap, i))) +} + +/// The distance along a path of length `len` (closed or not) to object `i`, from `start` and +/// `gap` apart as [`layout`] gives them. +pub fn nth_length(len: f64, closed: bool, start: f64, gap: f64, i: usize) -> f64 { + let s = start + gap * i as f64; + if closed { wrap(s, len) } else { s.min(len) } } /// The tilt Rotate gives (radians, the document's axes, where y points down): `rotate` degrees @@ -277,7 +396,7 @@ pub fn place(g: &mut Node, symbols: &SymbolBoxes) -> bool { } } let mut moved = false; - if let Some(stops) = path.and_then(|p| stops(&p, rec.members.len())) { + if let Some(stops) = path.and_then(|p| stops(&p, rec.members.len(), rec.spacing, rec.start)) { 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 }; @@ -309,15 +428,192 @@ mod tests { #[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 s = stops(&open, 4, None, 0.0).unwrap(); let xs: Vec = 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 s = stops(&closed, 4, None, 0.0).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()); + assert!(stops(&shapes::line(Point::ZERO, Point::ZERO), 2, None, 0.0).is_none()); + } + + /// The x of each stop on the `len` pt line from (0, 0) to the right, to 1/1000 pt. + fn xs_on_line(len: f64, n: usize, spacing: Option, start: f64) -> Vec { + let open = shapes::line(Point::new(0.0, 0.0), Point::new(len, 0.0)); + stops(&open, n, spacing, start).unwrap().iter().map(|(p, _)| (p.x * 1000.0).round() / 1000.0).collect() + } + + #[test] + fn space_sets_the_distance_between_the_stops() { + assert_eq!(xs_on_line(300.0, 4, Some(60.0), 0.0), [0.0, 60.0, 120.0, 180.0], "the first stays on the first anchor"); + assert_eq!(xs_on_line(300.0, 4, Some(60.0), 60.0), [60.0, 120.0, 180.0, 240.0], "centered on the path"); + assert_eq!(xs_on_line(300.0, 4, Some(0.0), 0.0), [0.0; 4], "no space stacks them"); + // More than the path has room for: the last one reaches the end. + assert_eq!(xs_on_line(300.0, 4, Some(500.0), 0.0), [0.0, 100.0, 200.0, 300.0]); + assert_eq!(xs_on_line(300.0, 4, Some(500.0), 150.0), [150.0, 200.0, 250.0, 300.0]); + // A shorter path draws them closer, while the Space value stays to come back with the length. + assert_eq!(xs_on_line(90.0, 4, Some(60.0), 0.0), [0.0, 30.0, 60.0, 90.0]); + assert_eq!(layout(90.0, false, 4, Some(60.0), 0.0), (0.0, 30.0)); + assert_eq!(layout(300.0, false, 4, Some(60.0), 0.0), (0.0, 60.0)); + // Values that are not usable count as Auto, and a start beyond the end stops at it. + assert_eq!(xs_on_line(300.0, 4, Some(f64::NAN), f64::INFINITY), [0.0, 100.0, 200.0, 300.0]); + assert_eq!(xs_on_line(300.0, 4, Some(-5.0), 0.0), [0.0, 100.0, 200.0, 300.0]); + assert_eq!(xs_on_line(300.0, 2, Some(10.0), 400.0), [300.0, 300.0]); + assert_eq!(xs_on_line(300.0, 1, Some(10.0), 20.0), [20.0], "one object has no space to set"); + assert_eq!(xs_on_line(300.0, 0, Some(10.0), 0.0), Vec::::new()); + // One stop on its own is where the stops put it. + let open = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0)); + let all = stops(&open, 4, Some(60.0), 60.0).unwrap(); + for (i, s) in all.iter().enumerate() { + assert_eq!(stop(&open, 4, Some(60.0), 60.0, i), Some(*s)); + } + assert_eq!(stop(&open, 4, Some(60.0), 60.0, 4), None); + } + + #[test] + fn space_on_a_closed_path_wraps_at_the_first_anchor_and_stops_short_of_the_first_object() { + // The square's outline is 400 pt round, from (0, 0) to the right and on clockwise. + let closed = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)); + let pts = |spacing, start| -> Vec<(i64, i64)> { + stops(&closed, 4, spacing, start).unwrap().iter().map(|(p, _)| (p.x.round() as i64, p.y.round() as i64)).collect() + }; + assert_eq!(pts(Some(50.0), 0.0), [(0, 0), (50, 0), (100, 0), (100, 50)]); + // Centered on the first anchor: the span goes back past it. + assert_eq!(pts(Some(50.0), -75.0), [(0, 75), (0, 25), (25, 0), (75, 0)]); + // At most a quarter of the way round each: no two objects meet at the first anchor. + assert_eq!(pts(Some(300.0), 0.0), [(0, 0), (100, 0), (100, 100), (0, 100)]); + assert_eq!(layout(400.0, true, 4, Some(300.0), 400.0), (0.0, 100.0)); + } + + #[test] + fn a_group_saved_without_space_reads_as_auto() { + let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)); + let rec = ObjectsOnPath::new(NodeId(1), &[&a], CENTER, 0.0); + let v = serde_json::to_value(&rec).unwrap(); + assert!(v.get("spacing").is_none() && v.get("start").is_none(), "Auto is not written: {v}"); + let back: ObjectsOnPath = serde_json::from_value(v).unwrap(); + assert_eq!((back.spacing, back.start), (None, 0.0)); + let set = ObjectsOnPath { spacing: Some(60.0), start: 12.5, ..rec }; + let back: ObjectsOnPath = serde_json::from_value(serde_json::to_value(&set).unwrap()).unwrap(); + assert_eq!(back, set); + } + + #[test] + fn transforming_the_group_scales_its_space() { + let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)); + let mut rec = ObjectsOnPath { spacing: Some(60.0), start: 12.5, ..ObjectsOnPath::new(NodeId(1), &[&a], CENTER, 30.0) }; + rec.transform(Affine::new([-2.0, 0.0, 0.0, 2.0, 5.0, 5.0]), None); + assert_eq!((rec.spacing, rec.start, rec.rotate), (Some(120.0), 25.0, -30.0)); + rec.transform(Affine::translate((40.0, 0.0)) * Affine::rotate(0.7), None); + assert!((rec.spacing.unwrap() - 120.0).abs() < 1e-9 && (rec.start - 25.0).abs() < 1e-9, "{rec:?}"); + // Twice as wide and no higher: a level path is stretched twice along its length. + let level = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0)); + let stretch = Affine::new([2.0, 0.0, 0.0, 1.0, 0.0, 0.0]); + rec.transform(stretch, Some((&level, &level.transformed(stretch)))); + assert!((rec.spacing.unwrap() - 240.0).abs() < 1e-9 && (rec.start - 50.0).abs() < 1e-9, "{rec:?}"); + let mut auto = ObjectsOnPath::new(NodeId(1), &[&a], CENTER, 0.0); + auto.transform(Affine::scale(3.0), None); + assert_eq!((auto.spacing, auto.start), (None, 0.0), "Auto stays Auto"); + let mut huge = ObjectsOnPath { spacing: Some(1e308), start: -1e308, ..auto }; + huge.transform(Affine::scale(2.0), None); + assert_eq!((huge.spacing, huge.start), (Some(1e308), -1e308), "kept finite"); + } + + /// Stretching a path that turns a corner keeps the first and last objects where the stretch + /// takes them, not where the whole path's change in length would. + #[test] + fn a_stretch_keeps_the_first_and_last_objects_where_it_takes_them() { + // 100 pt to the right, then 100 pt down; twice as wide, the first part is 200 pt long. + let points = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0)]; + let corner = PathData::single(vectorcraft_geom::SubPath::polyline(&points, false)); + let stretch = Affine::scale_non_uniform(2.0, 1.0); + let wide = corner.transformed(stretch); + let (a, b) = (rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)), rect(3, Rect::new(20.0, 0.0, 30.0, 10.0))); + let mut rec = ObjectsOnPath { spacing: Some(40.0), start: 50.0, ..ObjectsOnPath::new(NodeId(1), &[&a, &b], CENTER, 0.0) }; + rec.transform(stretch, Some((&corner, &wide))); + assert!((rec.start - 100.0).abs() < 1e-9 && (rec.spacing.unwrap() - 80.0).abs() < 1e-9, "{rec:?}"); + // Round a closed path, from before its first anchor: the start goes round as many times. + let square = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)); + let wide = square.transformed(stretch); + let mut rec = ObjectsOnPath { spacing: Some(50.0), start: -75.0, ..ObjectsOnPath::new(NodeId(1), &[&a, &b], CENTER, 0.0) }; + let before = stops(&square, 2, rec.spacing, rec.start).unwrap(); + rec.transform(stretch, Some((&square, &wide))); + let after = stops(&wide, 2, rec.spacing, rec.start).unwrap(); + for ((p, _), (q, _)) in before.iter().zip(&after) { + assert!((stretch * *p).distance(*q) < 1e-9, "{p:?} went to {q:?}: {rec:?}"); + } + assert!(rec.start < 0.0, "{rec:?}"); + } + + /// A start round a closed path so many times that scaling it would not be finite stays as it is. + #[test] + fn a_closed_path_start_too_large_to_scale_stays_finite() { + let square = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)); + let grow = Affine::scale(2.0); + let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)); + let mut rec = ObjectsOnPath { start: 1e308, ..ObjectsOnPath::new(NodeId(1), &[&a], CENTER, 0.0) }; + rec.transform(grow, Some((&square, &square.transformed(grow)))); + assert_eq!(rec.start, 1e308, "{rec:?}"); + } + + /// Stretching the group across its level path only keeps the objects where the stretch takes + /// them: twice as far apart. + #[test] + fn a_group_stretched_along_its_path_keeps_the_objects_in_proportion() { + let line = Node::path(NodeId(1), shapes::line(Point::new(0.0, 100.0), Point::new(300.0, 100.0)), Default::default()); + let (a, b) = (rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)), rect(3, Rect::new(20.0, 0.0, 30.0, 10.0))); + let mut g = Node::new(NodeId(4), NodeKind::Group { children: vec![Arc::new(line), Arc::new(a.clone()), Arc::new(b.clone())], clip: false }); + let rec = ObjectsOnPath { spacing: Some(20.0), start: 10.0, ..ObjectsOnPath::new(NodeId(1), &[&a, &b], CENTER, 0.0) }; + g.extras_mut().objects_on_path = Some(Box::new(rec)); + let empty = crate::Document::new(1.0, 1.0); + let symbols = empty.symbol_boxes(); + place(&mut g, &symbols); + g.transform(Affine::new([2.0, 0.0, 0.0, 1.0, 0.0, 0.0]), false); + let rec = g.extras().objects_on_path.as_deref().cloned().unwrap(); + assert_eq!((rec.spacing, rec.start), (Some(40.0), 20.0)); + place(&mut g, &symbols); + let x = |id| g.children().and_then(|c| c.iter().find(|c| c.id == id).cloned()).unwrap().geometric_bounds().unwrap().center().x; + assert!((x(NodeId(2)) - 20.0).abs() < 1e-9 && (x(NodeId(3)) - 60.0).abs() < 1e-9, "{} {}", x(NodeId(2)), x(NodeId(3))); + } + + #[test] + fn length_near_measures_along_the_path_and_past_its_ends() { + // An L: 400 pt to the right, then 300 pt up. + let points = [Point::new(0.0, 300.0), Point::new(400.0, 300.0), Point::new(400.0, 0.0)]; + let l = PathData::single(vectorcraft_geom::SubPath::polyline(&points, false)); + let spine = Spine::new(&l).unwrap(); + assert!((spine.length_near(Point::new(300.0, 320.0), None) - 300.0).abs() < 1e-9); + assert!((spine.length_near(Point::new(420.0, 100.0), None) - 600.0).abs() < 1e-9); + assert!((spine.length_near(Point::new(400.0, -26.0), None) - 726.0).abs() < 1e-9, "beyond the end"); + assert!((spine.length_near(Point::new(-50.0, 300.0), None) + 50.0).abs() < 1e-9, "before the start"); + // A path that turns back near its start: beyond its end, the pointer is nearer the first + // segment than the last one, but on the line of the last one. + let hook = [Point::new(0.0, 0.0), Point::new(200.0, 0.0), Point::new(200.0, 10.0), Point::new(100.0, 10.0)]; + let hook = Spine::new(&PathData::single(vectorcraft_geom::SubPath::polyline(&hook, false))).unwrap(); + assert!((hook.length_near(Point::new(80.0, 10.0), None) - 330.0).abs() < 1e-9); + assert!((hook.length_near(Point::new(50.0, 10.0), None) - 360.0).abs() < 1e-9); + let closed = Spine::new(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0))).unwrap(); + assert!((closed.length_near(Point::new(-30.0, -30.0), None)).abs() < 1e-9, "a closed path has no ends"); + } + + #[test] + fn length_near_a_point_followed_stays_on_that_part_of_the_path() { + // A narrow U, open on the left: its ends are 20 pt apart, 620 pt from each other along it. + let u = [Point::new(0.0, 0.0), Point::new(300.0, 0.0), Point::new(300.0, 20.0), Point::new(0.0, 20.0)]; + let u = Spine::new(&PathData::single(vectorcraft_geom::SubPath::polyline(&u, false))).unwrap(); + assert!((u.length_near(Point::new(-26.0, 20.0), None) - 646.0).abs() < 1e-9, "beyond the end"); + // Nearer the first segment's line, the pointer stays beyond the end it was following. + assert!((u.length_near(Point::new(-26.0, 8.0), None) + 26.0).abs() < 1e-9); + assert!((u.length_near(Point::new(-26.0, 8.0), Some(646.0)) - 646.0).abs() < 1e-9); + // Following the path round, it goes on from where it was. + assert!((u.length_near(Point::new(305.0, 10.0), Some(312.0)) - 310.0).abs() < 1e-9); + // Round a closed path's first anchor: from 10 pt short of it to 20 pt past it. + let square = Spine::new(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0))).unwrap(); + let (at, _) = square.at_length(390.0); + assert!((square.length_near(at, None) - 390.0).abs() < 1e-9); + assert!((square.length_near(Point::new(20.0, -5.0), Some(390.0)) - 20.0).abs() < 1e-9); } #[test] diff --git a/crates/engine/src/cmd/objects_on_path.rs b/crates/engine/src/cmd/objects_on_path.rs index eebdd3e04..e2bd0957d 100644 --- a/crates/engine/src/cmd/objects_on_path.rs +++ b/crates/engine/src/cmd/objects_on_path.rs @@ -7,7 +7,8 @@ //! 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. Pivot and Rotate come from the command's params, otherwise //! from the tool's options (Objects on Path Tool Options). `object.objectsOnPath.options` sets the -//! group's Pivot and Rotate, and `object.objectsOnPath.expand` makes it a plain group. +//! group's Pivot, Rotate and Space (the distance between the objects along the path, and where the +//! first one attaches), 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, @@ -48,7 +49,7 @@ pub fn specs() -> Vec { "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}", + "{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, spacing?: points along the path from each object's attachment point to the next one's (0 or more, kept to what the path has room for; or \"auto\", the default: spread evenly with the last object at the end of an open path), start?: points along the path from its first anchor to the first object's attachment point (default 0)} set how the objects of the groups attach and are spaced → {ids}", has_objects_on_path, options ), @@ -256,16 +257,37 @@ fn attach(s: &mut Session, p: &Value) -> Result { // ---------- Options ---------- +/// `spacing` and `start` from `p`, each checked: `spacing` takes points (0 or more) or `"auto"` +/// (`None`), and `start` points along the path. +fn spacing_start(p: &Value, cmd: &str, spacing: Option, start: f64) -> Result<(Option, f64)> { + let spacing = match p.get("spacing").filter(|v| !v.is_null()) { + None => spacing, + Some(Value::String(a)) if a == "auto" => None, + Some(v) => Some( + v.as_f64() + .filter(|g| g.is_finite() && *g >= 0.0) + .ok_or_else(|| bad(cmd, "`spacing` must be a number of points, 0 or more, or \"auto\""))?, + ), + }; + let start = match p.get("start").filter(|v| !v.is_null()) { + None => start, + Some(v) => v.as_f64().filter(|s| s.is_finite()).ok_or_else(|| bad(cmd, "`start` must be a finite number"))?, + }; + Ok((spacing, start)) +} + fn options(s: &mut Session, p: &Value) -> Result { const C: &str = "object.objectsOnPath.options"; let groups = target_groups(s, p, C)?; pivot_rotate(p, C, CENTER, 0.0)?; + spacing_start(p, C, None, 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 })); + let (spacing, start) = spacing_start(p, C, rec.spacing, rec.start)?; + n.extras_mut().objects_on_path = Some(Box::new(ObjectsOnPath { pivot, rotate, spacing, start, ..rec })); } Ok(()) })?; diff --git a/crates/engine/src/tests/objects_on_path.rs b/crates/engine/src/tests/objects_on_path.rs index a309eb69b..8c0f359df 100644 --- a/crates/engine/src/tests/objects_on_path.rs +++ b/crates/engine/src/tests/objects_on_path.rs @@ -460,3 +460,91 @@ fn options_and_expand_take_ids_without_a_selection() { 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"); } + +/// The Space and start of group `g`. +fn space(s: &Session, g: NodeId) -> (Option, f64) { + let rec = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap(); + (rec.spacing, rec.start) +} + +#[test] +fn space_sets_the_distance_between_the_objects_in_one_undo_step() { + 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})); + assert_eq!(space(&s, g), (None, 0.0), "Auto"); + let before = (bounds(&s, left), bounds(&s, middle).width()); + s.execute("object.objectsOnPath.options", &json!({"spacing": 60})).unwrap(); + assert_eq!(space(&s, g), (Some(60.0), 0.0)); + // The first object stays; the others come 60 pt apart, keeping Pivot and Rotate. + assert_eq!(bounds(&s, left), before.0); + let x = |s: &Session, id| bounds(s, id).center().x - bounds(s, left).center().x; + assert!((x(&s, middle) - 60.0).abs() < 1e-6 && (x(&s, right) - 120.0).abs() < 1e-6); + assert!((bounds(&s, middle).width() - before.1).abs() < 1e-6, "still turned by Rotate"); + // Centered: from 90 pt along the 300 pt line. + s.execute("object.objectsOnPath.options", &json!({"spacing": 60, "start": 90})).unwrap(); + assert!((x(&s, right) - 120.0).abs() < 1e-6); + assert!((bounds(&s, left).center().x - before.0.center().x - 90.0).abs() < 1e-6); + // Pivot and Rotate keep the Space. + s.execute("object.objectsOnPath.options", &json!({"rotate": 0, "pivot": 4})).unwrap(); + assert_eq!(space(&s, g), (Some(60.0), 90.0)); + assert_eq!( + (center(&s, left), center(&s, middle), center(&s, right)), + (Point::new(140.0, 300.0), Point::new(200.0, 300.0), Point::new(260.0, 300.0)) + ); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(space(&s, g), (Some(60.0), 0.0), "each change is one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(space(&s, g), (None, 0.0)); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(space(&s, g), (Some(60.0), 0.0)); + s.execute("object.objectsOnPath.options", &json!({"spacing": "auto", "start": 0})).unwrap(); + assert_eq!(space(&s, g), (None, 0.0)); + assert_eq!(center(&s, right).x - center(&s, left).x, 300.0, "Auto spreads them to the end again"); +} + +#[test] +fn a_space_that_is_not_usable_is_refused_and_one_too_large_is_kept_to_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!({})); + for bad in + [json!({"spacing": -1}), json!({"spacing": "wide"}), json!({"spacing": true}), json!({"start": "x"}), json!({"spacing": 10, "start": [1]})] + { + assert!(s.execute("object.objectsOnPath.options", &bad).is_err(), "{bad}"); + assert_eq!(space(&s, g), (None, 0.0), "{bad} changes nothing"); + } + s.execute("object.objectsOnPath.options", &json!({"spacing": 1e9})).unwrap(); + assert_eq!(space(&s, g), (Some(1e9), 0.0)); + assert_eq!((center(&s, middle), center(&s, right)), (Point::new(200.0, 300.0), Point::new(350.0, 300.0)), "the last one reaches the end"); + // A shorter path draws them closer; the full length brings the Space back. + s.execute("object.objectsOnPath.options", &json!({"spacing": 100})).unwrap(); + set_line(&mut s, path, (50.0, 300.0), (150.0, 300.0)); + assert_eq!((center(&s, middle), center(&s, right)), (Point::new(100.0, 300.0), Point::new(150.0, 300.0))); + assert_eq!(space(&s, g), (Some(100.0), 0.0)); + set_line(&mut s, path, (50.0, 300.0), (350.0, 300.0)); + assert_eq!((center(&s, middle), center(&s, right)), (Point::new(150.0, 300.0), Point::new(250.0, 300.0))); + // An object moved on its own goes back, and the Space stays. + s.execute("object.move", &json!({"ids": [middle.0], "dx": 30, "dy": -40})).unwrap(); + assert_eq!(center(&s, middle), Point::new(150.0, 300.0)); + assert_eq!(space(&s, g), (Some(100.0), 0.0)); +} + +#[test] +fn the_space_saves_and_reopens_and_goes_with_a_copy() { + 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.objectsOnPath.options", &json!({"spacing": 40, "start": 25})).unwrap(); + 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(); + assert_eq!(space(&o, g), (Some(40.0), 25.0)); + assert_eq!(center(&o, right), Point::new(155.0, 300.0)); + 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(); + assert_eq!(space(&s, copy[0]), (Some(40.0), 25.0)); +} diff --git a/crates/eps/src/import/ai/read/on_path.rs b/crates/eps/src/import/ai/read/on_path.rs index 4fe14e854..1e33e09a1 100644 --- a/crates/eps/src/import/ai/read/on_path.rs +++ b/crates/eps/src/import/ai/read/on_path.rs @@ -182,7 +182,7 @@ fn apply_one(doc: &mut Document, found: &OnPath, at: &HashMap let center = doc.node_at(place)?.geometric_bounds()?.center(); members.push(Member { id, frame: Affine::rotate_about(angle, center), angle }); } - let on_path = ObjectsOnPath { path, members, pivot: r.pivot, rotate: r.rotate }; + let on_path = ObjectsOnPath { path, members, pivot: r.pivot, rotate: r.rotate, spacing: None, start: 0.0 }; doc.node_mut_at(group_at)?.extras_mut().objects_on_path = Some(Box::new(on_path)); Some(()) } diff --git a/crates/tools/src/objects_on_path.rs b/crates/tools/src/objects_on_path.rs index 47aff8910..fe5a8b921 100644 --- a/crates/tools/src/objects_on_path.rs +++ b/crates/tools/src/objects_on_path.rs @@ -11,10 +11,21 @@ //! previewed while dragging and kept as one undo step on release. Shift turns in steps of 15°, and //! Escape cancels the drag. //! -//! Behavior: . +//! The Space widget: with one Objects on Path group of two or more objects selected, the same +//! tools show a square handle beyond the group's last object, ahead along the path. Dragging it +//! along the path sets the distance between the objects' attachment points +//! (`object.objectsOnPath.options` with `spacing` and `start`): the first object stays where it is +//! and the others move toward or away from it. With Alt (Option on macOS) the objects spread from +//! both ends toward or away from the center of an open path, or of the first anchor of a closed +//! one. The distance shows next to the pointer in the document's units, the drag is previewed and +//! kept as one undo step on release, and Escape cancels it. +//! +//! Behavior: , +//! . use serde_json::{Value, json}; -use vectorcraft_doc::objects_on_path::{CENTER, attachable_path, is_objects_on_path, stops}; +use vectorcraft_doc::blend::Spine; +use vectorcraft_doc::objects_on_path::{CENTER, ObjectsOnPath, attachable_path, is_objects_on_path, layout, nth_length, path_length, stop}; use vectorcraft_doc::{Document, NodeId, Selection}; use vectorcraft_geom::{BezPath, Point, Vec2}; @@ -33,6 +44,35 @@ const ROTATE_ALL_HIT_PX: f64 = 6.0; /// The step (degrees) a Shift-drag of the Rotate All widget turns by. const ROTATE_ALL_STEP: f64 = 15.0; +/// The Space widget's center sits this far (screen px) beyond the last object, ahead along the path. +const SPACE_OFFSET_PX: f64 = 16.0; +/// Half the side of the Space widget's square (screen px), as the canvas draws it. +pub const SPACE_HALF_PX: f32 = 3.5; +/// How near (screen px) the pointer must be to hit the Space widget. +const SPACE_HIT_PX: f64 = 6.0; + +/// The Objects on Path group the widgets show for at `zoom` (screen px per document point), with +/// its record, its children and its path: only when the selection is that one editable group, on +/// a path with length, and no anchors are Direct-Selected. +fn one_group<'a>( + doc: &'a Document, + selection: &Selection, + zoom: f64, +) -> Option<(NodeId, &'a ObjectsOnPath, &'a [std::sync::Arc], &'a vectorcraft_geom::PathData)> { + if !selection.anchors.is_empty() || !zoom.is_finite() || zoom <= 0.0 { + return None; + } + let [group] = selection.objects.as_slice() else { return None }; + if !doc.editable()(*group) { + return None; + } + let n = doc.node(*group)?; + let rec = n.extras().objects_on_path.as_deref()?; + let children = n.children()?; + let path = attachable_path(children.iter().find(|c| c.id == rec.path)?)?; + Some((*group, rec, children.as_slice(), path)) +} + /// The Rotate All widget of a selected Objects on Path group. #[derive(Clone, Copy, Debug, PartialEq)] pub struct RotateAllWidget { @@ -50,20 +90,9 @@ impl RotateAllWidget { /// selection is one editable Objects on Path group with at least one object on a path with /// length, and no anchors are Direct-Selected. pub fn of(doc: &Document, selection: &Selection, zoom: f64) -> Option { - if !selection.anchors.is_empty() || !zoom.is_finite() || zoom <= 0.0 { - return None; - } - let [group] = selection.objects.as_slice() else { return None }; - if !doc.editable()(*group) { - return None; - } - let n = doc.node(*group)?; - let rec = n.extras().objects_on_path.as_deref()?; - let children = n.children()?; - let path = attachable_path(children.iter().find(|c| c.id == rec.path)?)?; + let (group, rec, children, path) = one_group(doc, selection, zoom)?; let first = rec.members.first()?; - // The first object attaches at the path's start, whatever the number of objects. - let (pivot, _) = *stops(path, 1)?.first()?; + let (pivot, _) = stop(path, rec.members.len(), rec.spacing, rec.start, 0)?; let art = children.iter().find(|c| c.id == first.id)?; let b = art.geometric_bounds_with(&doc.symbol_boxes())?; // The object's top is the -y direction of the frame it is drawn in (the document's y axis @@ -80,7 +109,7 @@ impl RotateAllWidget { let reach = corners.iter().map(|c| (*c - pivot).dot(up)).fold(0.0, f64::max); let at = pivot + up * (reach + ROTATE_ALL_OFFSET_PX / zoom); let finite = at.x.is_finite() && at.y.is_finite() && pivot.x.is_finite() && pivot.y.is_finite(); - finite.then_some(Self { group: *group, pivot, at, rotate: rec.rotate }) + finite.then_some(Self { group, pivot, at, rotate: rec.rotate }) } /// The widget of `cx` when `p` is within reach of it. @@ -166,6 +195,205 @@ impl RotateAllDrag { } } +/// The Space widget of a selected Objects on Path group. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SpaceWidget { + pub group: NodeId, + /// The last object's attachment point, in the document. + pub last: Point, + /// The distance along the path to the last object's attachment point (points). + pub last_length: f64, + /// The widget's center in the document. + pub at: Point, + /// The path's direction at the last object's attachment point: a unit vector in the + /// document's axes, in which the widget sits beyond the object. + pub dir: Vec2, + /// The length of the path (points). + pub len: f64, + /// Is the path closed? + pub closed: bool, + /// The number of objects on the path: 2 or more. + pub count: usize, + /// Where the first object attaches along the path (points from its first anchor), as drawn. + pub start: f64, + /// The distance along the path between neighboring objects' attachment points (points), as drawn. + pub gap: f64, + /// The group's Space value (`None`: Auto) and start, as saved: more than is drawn when the path + /// is too short for them. + pub saved: (Option, f64), +} + +impl SpaceWidget { + /// The widget of the selection at `zoom` (screen px per document point): only when the + /// selection is one editable Objects on Path group with two or more objects on a path with + /// length, and no anchors are Direct-Selected. It sits beyond the last object's bounds, ahead + /// along the path's direction there. + pub fn of(doc: &Document, selection: &Selection, zoom: f64) -> Option { + let (group, rec, children, path) = one_group(doc, selection, zoom)?; + let count = rec.members.len(); + if count < 2 { + return None; + } + let (len, closed) = path_length(path)?; + let (start, gap) = layout(len, closed, count, rec.spacing, rec.start); + let (last, angle) = stop(path, count, rec.spacing, rec.start, count - 1)?; + let dir = Vec2::new(angle.cos(), angle.sin()); + let m = rec.members.last()?; + let b = children.iter().find(|c| c.id == m.id)?.geometric_bounds_with(&doc.symbol_boxes())?; + let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x0, b.y1), Point::new(b.x1, b.y1)]; + let reach = corners.iter().map(|c| (*c - last).dot(dir)).fold(0.0, f64::max); + let at = last + dir * (reach + SPACE_OFFSET_PX / zoom); + let finite = at.x.is_finite() && at.y.is_finite() && last.x.is_finite() && last.y.is_finite(); + let last_length = nth_length(len, closed, start, gap, count - 1); + finite.then_some(Self { group, last, last_length, at, dir, len, closed, count, start, gap, saved: (rec.spacing, rec.start) }) + } + + /// The widget of `cx` when `p` is within reach of it. + fn at_point(cx: &ToolContext, p: Point) -> Option { + Self::of(cx.doc, cx.selection, cx.zoom).filter(|w| w.at.distance(p) <= cx.tol(SPACE_HIT_PX)) + } +} + +/// Is `p` over the Space widget? +pub fn over_space(cx: &ToolContext, p: Point) -> bool { + SpaceWidget::at_point(cx, p).is_some() +} + +/// The distance along the path of the Objects on Path group of `w` in `doc` to the point nearest +/// `p` ([`Spine::nearest`]: past the ends of an open path, along the lines of its end segments), +/// staying near `from` when the pointer is following the path from there. The line from the last +/// object's attachment point through the widget counts as the path too, as far as the pointer is +/// ahead of that object along it, so a widget beyond a corner measures from where it is. The flag +/// says whether the point is on that line, where the distance runs on past the end of a closed path +/// instead of round it. +fn length_near(doc: &Document, w: &SpaceWidget, p: Point, from: Option) -> Option<(f64, bool)> { + let n = doc.node(w.group)?; + let rec = n.extras().objects_on_path.as_deref()?; + let path = attachable_path(n.children()?.iter().find(|c| c.id == rec.path)?)?; + let spine = Spine::new(path)?; + let bias = |s: f64| from.filter(|f| f.is_finite()).map_or(0.0, |f| spine.apart(s, f) / 2.0); + let ahead = (p - w.last).dot(w.dir); + let line = (ahead >= 0.0).then(|| { + let s = w.last_length + ahead; + ((p - w.last).cross(w.dir).abs() + bias(s), s) + }); + let (s, on_line) = match (spine.nearest(p, from), line) { + (Some(on), Some(line)) if line.0 < on.0 => (line.1, true), + (Some(on), _) => (on.1, false), + (None, line) => (line?.1, true), + }; + s.is_finite().then_some((s, on_line)) +} + +/// Dragging the Space widget: the distance the pointer moves along the path moves the last object +/// along it as far, and the objects between follow evenly. With Alt the first object moves the +/// other way as far, so the objects spread from both ends. +#[derive(Clone, Debug)] +pub struct SpaceDrag { + widget: SpaceWidget, + start: Point, + began: bool, + /// The distance along the path to the point nearest the pointer at the last drag (points). + last: f64, + /// The distance the pointer has moved along the path since the press (points). + along: f64, + /// The distance between neighboring objects the drag sets (points). + gap: f64, + at: Point, +} + +impl SpaceDrag { + /// The drag has left the click: the distance is changing. + pub fn adjusting(&self) -> bool { + self.began + } + + /// Start a drag when the press `ev` is on the Space widget. + pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option { + let widget = SpaceWidget::at_point(cx, ev.pos)?; + let (last, _) = length_near(cx.doc, &widget, ev.pos, None)?; + Some(Self { widget, start: ev.pos, began: false, last, along: 0.0, gap: widget.gap, at: ev.pos }) + } + + /// The pointer moved to `p` from `self.at`: add the distance it moved along the path. On the + /// line through the widget, from a point ahead of the last object, that is how far it moved + /// along the line; otherwise it takes the shorter way round a closed path. + fn follow(&mut self, doc: &Document, p: Point) { + let w = &self.widget; + let Some((t, on_line)) = length_near(doc, w, p, Some(self.last)) else { return }; + let before = (self.at - w.last).dot(w.dir); + let step = if on_line && before >= 0.0 { + (p - w.last).dot(w.dir) - before + } else if w.closed { + (t - self.last + w.len / 2.0).rem_euclid(w.len) - w.len / 2.0 + } else { + t - self.last + }; + if step.is_finite() { + self.along += step; + self.last = t; + } + } + + /// The Space and start the drag sets: without `alt` the first object stays and the last one goes + /// no further than the end of an open path; with `alt` the objects are centered on an open path, + /// or on the first anchor of a closed one, and spread over the whole path at most. On a closed + /// path the objects go no more than once around. + fn spacing_at(&self, alt: bool) -> (f64, f64) { + let w = &self.widget; + let k = (w.count - 1) as f64; + let along = self.along; + let round = w.len / w.count as f64; + if alt { + let most = if w.closed { round } else { w.len / k }; + let gap = (w.gap + 2.0 * along / k).clamp(0.0, most); + let span = gap * k; + (gap, if w.closed { (-span / 2.0).rem_euclid(w.len) } else { (w.len - span) / 2.0 }) + } else { + let most = if w.closed { round } else { (w.len - w.start) / k }; + ((w.gap + along / k).clamp(0.0, most.max(0.0)), w.start) + } + } + + /// The pointer moved to `p`; `alt` spreads the objects from both ends. + pub fn drag(&mut self, cx: &ToolContext, p: Point, alt: bool) -> Vec { + let mut out = vec![]; + if !self.began { + if p.distance(self.start) < cx.tol(3.0) { + return out; + } + self.began = true; + out.push(Action::Begin("Objects on Path Options".into())); + } + self.follow(cx.doc, p); + let (gap, start) = self.spacing_at(alt); + self.gap = gap; + self.at = p; + // Where the objects stay as they are drawn, the group keeps its saved values: Auto, or a + // Space value the path is too short for. + let w = &self.widget; + let (spacing, start) = if (gap - w.gap).abs() < 1e-9 && (start - w.start).abs() < 1e-9 { + (w.saved.0.map_or_else(|| json!("auto"), |g| json!(g)), json!(w.saved.1)) + } else { + (json!(gap), json!(start)) + }; + out.push(Action::Preview("object.objectsOnPath.options".into(), json!({ "ids": [w.group.0], "spacing": spacing, "start": start }))); + out + } + + pub fn finish(self) -> Vec { + if self.began { vec![Action::Commit] } else { vec![] } + } + + /// The distance between the objects next to the pointer, in the document's units. + pub fn overlays(&self, cx: &ToolContext) -> Vec { + if !self.began || !cx.measurement_labels { + return vec![]; + } + vec![Overlay::Measure { p: self.at, text: cx.len(self.gap) }] + } +} + /// 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, is not a clipping path or part of a compound path, /// blend, envelope, repeat or Intertwine group ([`vectorcraft_doc::objects_on_path::held`]), and @@ -190,6 +418,8 @@ pub struct ObjectsOnPathTool { press: Option, /// The Rotate All widget drag, while the button is down after a press on the widget. rotating: Option, + /// The Space widget drag, while the button is down after a press on the widget. + spacing: Option, /// The reference point of each object attached next (0-8, row by row from the top left). pivot: u8, /// The angle of each object attached next, in degrees counter-clockwise from upright on the path. @@ -198,7 +428,7 @@ pub struct ObjectsOnPathTool { impl Default for ObjectsOnPathTool { fn default() -> Self { - Self { hover: None, press: None, rotating: None, pivot: CENTER, rotate: 0.0 } + Self { hover: None, press: None, rotating: None, spacing: None, pivot: CENTER, rotate: 0.0 } } } @@ -216,9 +446,16 @@ impl Tool for ObjectsOnPathTool { _ => vec![], }; } + if let Some(d) = &mut self.spacing { + return match ev.kind { + PointerKind::Drag => d.drag(cx, p, ev.mods.alt), + PointerKind::Up => self.spacing.take().map(SpaceDrag::finish).unwrap_or_default(), + _ => vec![], + }; + } match ev.kind { PointerKind::Move => { - self.hover = if over_rotate_all(cx, p) { None } else { target(cx, p) }; + self.hover = if over_rotate_all(cx, p) || over_space(cx, p) { None } else { target(cx, p) }; vec![] } PointerKind::Down => { @@ -227,6 +464,11 @@ impl Tool for ObjectsOnPathTool { self.hover = None; return vec![]; } + if let Some(d) = SpaceDrag::hit(cx, ev) { + self.spacing = Some(d); + self.hover = None; + return vec![]; + } self.press = Some(p); vec![] } @@ -247,12 +489,17 @@ impl Tool for ObjectsOnPathTool { if let Some(d) = &self.rotating { o.extend(d.overlays(cx)); } + if let Some(d) = &self.spacing { + o.extend(d.overlays(cx)); + } o } fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor { - if self.rotating.is_some() || over_rotate_all(cx, p) { + if self.rotating.is_some() || (self.spacing.is_none() && over_rotate_all(cx, p)) { Cursor::Rotate + } else if self.spacing.is_some() || over_space(cx, p) { + Cursor::ArrowHollow } else if target(cx, p).is_some() { Cursor::ObjectsOnPath } else { @@ -261,12 +508,14 @@ impl Tool for ObjectsOnPathTool { } /// Escape while the button is down cancels the click, so the release attaches nothing, or the - /// Rotate All drag, so the objects go back to the Rotate they had. + /// Rotate All or Space drag, so the objects go back to the Rotate or Space they had. fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { if key == ToolKey::Escape { self.press = None; self.hover = None; - if self.rotating.take().is_some_and(|d| d.adjusting()) { + let rotated = self.rotating.take().is_some_and(|d| d.adjusting()); + let spaced = self.spacing.take().is_some_and(|d| d.adjusting()); + if rotated || spaced { return vec![Action::Cancel]; } } @@ -296,11 +545,11 @@ impl Tool for ObjectsOnPathTool { } fn busy(&self) -> bool { - self.press.is_some() || self.rotating.is_some() + self.press.is_some() || self.rotating.is_some() || self.spacing.is_some() } fn transforming(&self) -> bool { - self.rotating.as_ref().is_some_and(RotateAllDrag::adjusting) + self.rotating.as_ref().is_some_and(RotateAllDrag::adjusting) || self.spacing.as_ref().is_some_and(SpaceDrag::adjusting) } } @@ -602,4 +851,228 @@ mod tests { assert!(t.pointer(&cx, &ev(PointerKind::Up, p.0, p.1)).is_empty(), "a click on {what} attaches nothing"); } } + + /// [`group_doc`] with the group's Space `spacing` from `start`, its objects placed again. + fn spaced_doc(spacing: Option, start: f64) -> (Document, NodeId, Selection) { + let (mut d, g, sel) = group_doc(0.0); + let empty = Document::new(1.0, 1.0); + let symbols = empty.symbol_boxes(); + let n = d.node_mut(g).unwrap(); + let rec = n.extras().objects_on_path.as_deref().cloned().unwrap(); + n.extras_mut().objects_on_path = Some(Box::new(vectorcraft_doc::objects_on_path::ObjectsOnPath { spacing, start, ..rec })); + vectorcraft_doc::objects_on_path::place(n, &symbols); + (d, g, sel) + } + + /// The Space and start a drag's last action previews, to 1/1,000,000 pt. + fn spaced(acts: &[Action]) -> Option<(f64, f64)> { + let round = |v: f64| (v * 1e6).round() / 1e6 + 0.0; + match acts.last() { + Some(Action::Preview(cmd, p)) if cmd == "object.objectsOnPath.options" => { + Some((round(p["spacing"].as_f64()?), round(p["start"].as_f64()?))) + } + _ => None, + } + } + + fn alt(kind: PointerKind, x: f64, y: f64) -> PointerEvent { + PointerEvent { mods: Mods { alt: true, ..Mods::default() }, ..ev(kind, x, y) } + } + + #[test] + fn the_space_widget_sits_beyond_the_last_object_ahead_along_the_path() { + // Auto: the second square sits on the end (400, 300) and spans 390 to 410. + let (d, g, sel) = group_doc(0.0); + let w = SpaceWidget::of(&d, &sel, 1.0).unwrap(); + assert_eq!((w.group, w.count, w.closed), (g, 2, false)); + assert!(near(w.last, Point::new(400.0, 300.0)) && near(w.at, Point::new(426.0, 300.0)), "{w:?}"); + assert!((w.len - 400.0).abs() < 1e-9 && w.start == 0.0 && (w.gap - 400.0).abs() < 1e-9, "{w:?}"); + assert!(near(SpaceWidget::of(&d, &sel, 2.0).unwrap().at, Point::new(418.0, 300.0))); + // With a Space set it follows the last object, and Rotate All follows the first one. + let (d, _, sel) = spaced_doc(Some(100.0), 50.0); + let w = SpaceWidget::of(&d, &sel, 1.0).unwrap(); + assert!(near(w.last, Point::new(150.0, 300.0)) && near(w.at, Point::new(176.0, 300.0)), "{w:?}"); + assert!(near(RotateAllWidget::of(&d, &sel, 1.0).unwrap().pivot, Point::new(50.0, 300.0))); + // No widget for an object in the group, or for a locked group. + let children: Vec = d.node(sel.objects[0]).and_then(|n| n.children()).unwrap().iter().map(|c| c.id).collect(); + let mut one = Selection::default(); + one.set([children[1]]); + assert!(SpaceWidget::of(&d, &one, 1.0).is_none()); + let (mut d, g, sel) = group_doc(0.0); + if let Some(n) = d.node_mut(g) { + n.locked = true; + } + assert!(SpaceWidget::of(&d, &sel, 1.0).is_none()); + } + + /// Dragging the widget 100 pt back along the path brings the last object 100 pt nearer the + /// first one, which stays; with Alt the first one comes as far the other way. Switching Alt + /// during the drag works from where the drag began. + #[test] + fn dragging_the_space_widget_spaces_the_objects_from_the_first_one() { + let (d, g, sel) = group_doc(0.0); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let at = SpaceWidget::of(&d, &sel, 1.0).unwrap().at; + let tools: [Box; 2] = [Box::new(crate::select::SelectionTool::default()), Box::new(ObjectsOnPathTool::default())]; + for mut t in tools { + let id = t.id(); + assert_eq!(t.cursor(&c, at, Mods::default()), Cursor::ArrowHollow, "{id}"); + assert!(t.pointer(&c, &ev(PointerKind::Down, at.x, at.y)).is_empty(), "{id}"); + let a = t.pointer(&c, &ev(PointerKind::Drag, at.x - 100.0, at.y + 30.0)); + assert_eq!(a.first(), Some(&Action::Begin("Objects on Path Options".into())), "{id}"); + assert!(matches!(a.last(), Some(Action::Preview(_, p)) if p["ids"] == json!([g.0])), "{a:?}"); + assert_eq!(spaced(&a), Some((300.0, 0.0)), "{id}: the first object stays"); + assert!(t.transforming() && t.cursor(&c, at, Mods::default()) == Cursor::ArrowHollow, "{id}"); + let label = c.len(300.0); + assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if *text == label)), "{id}"); + assert_eq!(spaced(&t.pointer(&c, &alt(PointerKind::Drag, at.x - 100.0, at.y))), Some((200.0, 100.0)), "{id}: from both ends"); + assert_eq!(spaced(&t.pointer(&c, &ev(PointerKind::Drag, at.x - 100.0, at.y))), Some((300.0, 0.0)), "{id}: Alt let go"); + // No further than the end, where the objects are as drawn and the group stays Auto; and no + // less than 0. + let a = t.pointer(&c, &ev(PointerKind::Drag, at.x + 50.0, at.y)); + assert!(matches!(a.last(), Some(Action::Preview(_, p)) if p["spacing"] == "auto" && p["start"] == 0.0), "{id}: {a:?}"); + assert_eq!(spaced(&t.pointer(&c, &ev(PointerKind::Drag, at.x - 900.0, at.y))), Some((0.0, 0.0)), "{id}"); + assert_eq!(spaced(&t.pointer(&c, &alt(PointerKind::Drag, at.x - 900.0, at.y))), Some((0.0, 200.0)), "{id}"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, at.x, at.y)), vec![Action::Commit], "{id}"); + assert!(!t.busy(), "{id}"); + // A click on the widget that does not move changes nothing. + assert!(t.pointer(&c, &ev(PointerKind::Down, at.x, at.y)).is_empty()); + assert!(t.pointer(&c, &ev(PointerKind::Up, at.x, at.y)).is_empty(), "{id}"); + } + } + + /// A Space value the path is too short for stays when a drag leaves the objects where they are + /// drawn, and a drag that moves them sets the drawn value. + #[test] + fn a_drag_that_leaves_the_objects_as_drawn_keeps_the_saved_space() { + let (d, _, sel) = spaced_doc(Some(1000.0), 0.0); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let w = SpaceWidget::of(&d, &sel, 1.0).unwrap(); + assert_eq!((w.gap, w.saved), (400.0, (Some(1000.0), 0.0)), "drawn to the end of the 400 pt path"); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, w.at.x, w.at.y)); + assert_eq!(spaced(&t.pointer(&c, &ev(PointerKind::Drag, w.at.x + 50.0, w.at.y))), Some((1000.0, 0.0))); + assert_eq!(spaced(&t.pointer(&c, &ev(PointerKind::Drag, w.at.x - 50.0, w.at.y))), Some((350.0, 0.0))); + assert_eq!(spaced(&t.pointer(&c, &ev(PointerKind::Drag, w.at.x, w.at.y))), Some((1000.0, 0.0)), "back where it began"); + } + + /// On a closed path the objects go no more than once around, and Alt centers them on the + /// first anchor. + #[test] + fn dragging_the_space_widget_on_a_closed_path_wraps_at_the_first_anchor() { + let w = SpaceWidget { + group: NodeId(1), + last: Point::ZERO, + last_length: 300.0, + at: Point::ZERO, + dir: Vec2::new(1.0, 0.0), + len: 400.0, + closed: true, + count: 4, + start: 0.0, + gap: 100.0, + saved: (None, 0.0), + }; + let moved = |along| SpaceDrag { widget: w, start: Point::ZERO, began: true, last: 0.0, along, gap: w.gap, at: Point::ZERO }; + assert_eq!(moved(30.0).spacing_at(false), (100.0, 0.0), "a quarter of the way round at most"); + assert_eq!(moved(-60.0).spacing_at(false), (80.0, 0.0)); + assert_eq!(moved(-60.0).spacing_at(true), (60.0, 310.0), "the span of 180 centered on the first anchor"); + } + + /// An Objects on Path group of two 20 × 20 squares on an L: 400 pt to the right from (0, 300), + /// then 300 pt up, with Space `spacing`; and a selection of the group. + fn corner_doc(spacing: Option) -> (Document, Selection) { + let points = [Point::new(0.0, 300.0), Point::new(400.0, 300.0), Point::new(400.0, 0.0)]; + path_doc(vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&points, false)), spacing) + } + + /// An Objects on Path group of two 20 × 20 squares on `path`, with Space `spacing`; and a + /// selection of the group. + fn path_doc(path: vectorcraft_geom::PathData, spacing: Option) -> (Document, Selection) { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (line, a, b, g) = (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id()); + let line = Node::path(line, path, Appearance::default_art()); + let a = Node::path(a, shapes::rectangle(Rect::new(50.0, 50.0, 70.0, 70.0)), Appearance::default_art()); + let b = Node::path(b, shapes::rectangle(Rect::new(100.0, 50.0, 120.0, 70.0)), Appearance::default_art()); + let rec = vectorcraft_doc::objects_on_path::ObjectsOnPath::new(line.id, &[&a, &b], CENTER, 0.0); + let rec = vectorcraft_doc::objects_on_path::ObjectsOnPath { spacing, ..rec }; + let mut group = Node::new(g, NodeKind::Group { children: vec![Arc::new(line), Arc::new(a), Arc::new(b)], clip: false }); + group.extras_mut().objects_on_path = Some(Box::new(rec)); + vectorcraft_doc::objects_on_path::place(&mut group, &Document::new(1.0, 1.0).symbol_boxes()); + d.insert(Some(l), 0, group).unwrap(); + let mut sel = Selection::default(); + sel.set([g]); + (d, sel) + } + + /// On an L-shaped path the drag counts the distance along the path, round the corner. + #[test] + fn a_space_drag_follows_the_path_round_a_corner() { + let (d, sel) = corner_doc(None); + let paint = paint(); + let c = cx(&d, &sel, &paint); + // The path is 700 pt long; the second object sits on its end (400, 0), and the widget above it. + let w = SpaceWidget::of(&d, &sel, 1.0).unwrap(); + assert!(near(w.last, Point::new(400.0, 0.0)) && (w.gap - 700.0).abs() < 1e-9, "{w:?}"); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, w.at.x, w.at.y)); + // The press, 26 pt beyond the end, is 726 pt along. Down the upright leg to 500 pt along, + // then round the corner onto the level one at 300 pt along. + assert_eq!(spaced(&t.pointer(&c, &ev(PointerKind::Drag, 400.0, 200.0))), Some((474.0, 0.0))); + assert_eq!(spaced(&t.pointer(&c, &ev(PointerKind::Drag, 300.0, 310.0))), Some((274.0, 0.0))); + } + + /// With the last object just short of the corner, the widget sits past the corner; the drag + /// measures from the widget, so the objects follow the pointer from the start. + #[test] + fn a_space_widget_past_a_corner_measures_from_where_it_is() { + let (d, sel) = corner_doc(Some(390.0)); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let w = SpaceWidget::of(&d, &sel, 1.0).unwrap(); + assert!(near(w.last, Point::new(390.0, 300.0)) && near(w.at, Point::new(416.0, 300.0)) && w.last_length == 390.0, "{w:?}"); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, w.at.x, w.at.y)); + assert_eq!(spaced(&t.pointer(&c, &ev(PointerKind::Drag, 396.0, 300.0))), Some((370.0, 0.0)), "20 pt back"); + assert_eq!(spaced(&t.pointer(&c, &ev(PointerKind::Drag, 386.0, 300.0))), Some((360.0, 0.0))); + } + + /// On a closed path, a drag far ahead along the line through the widget spreads the objects + /// apart, however far the pointer goes at once. + #[test] + fn a_long_drag_ahead_on_a_closed_path_spreads_the_objects() { + // The square's outline is 800 pt round, from (100, 100) to the right. + let (d, sel) = path_doc(shapes::rectangle(Rect::new(100.0, 100.0, 300.0, 300.0)), Some(100.0)); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let w = SpaceWidget::of(&d, &sel, 1.0).unwrap(); + assert!(near(w.last, Point::new(200.0, 100.0)) && near(w.at, Point::new(226.0, 100.0)), "{w:?}"); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, w.at.x, w.at.y)); + assert_eq!(spaced(&t.pointer(&c, &ev(PointerKind::Drag, 236.0, 100.0))), Some((110.0, 0.0))); + assert_eq!(spaced(&t.pointer(&c, &ev(PointerKind::Drag, 1000.0, 100.0))), Some((400.0, 0.0)), "half way round at most"); + } + + /// Escape during a Space drag cancels it: the readout is removed, and the release keeps + /// nothing. + #[test] + fn escape_cancels_a_space_drag() { + let (d, _, sel) = group_doc(0.0); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let at = SpaceWidget::of(&d, &sel, 1.0).unwrap().at; + let tools: [Box; 2] = [Box::new(crate::select::SelectionTool::default()), Box::new(ObjectsOnPathTool::default())]; + for mut t in tools { + t.pointer(&c, &ev(PointerKind::Down, at.x, at.y)); + assert!(spaced(&t.pointer(&c, &ev(PointerKind::Drag, at.x - 40.0, at.y))).is_some()); + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{}", t.id()); + assert!(!t.busy() && !t.transforming(), "{}", t.id()); + assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Measure { .. })), "{}", t.id()); + assert!(t.pointer(&c, &ev(PointerKind::Drag, at.x, at.y)).is_empty(), "{}", t.id()); + assert!(t.pointer(&c, &ev(PointerKind::Up, at.x, at.y)).is_empty(), "{}: the release keeps nothing", t.id()); + } + } } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 8cbade741..572c667e4 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -47,7 +47,7 @@ use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag}; use crate::corners::{self, CornerDrag, over_widget}; use crate::guides::{PointSnap, Targets, distance_guides, hold_on_path}; -use crate::objects_on_path::{RotateAllDrag, over_rotate_all}; +use crate::objects_on_path::{RotateAllDrag, SpaceDrag, over_rotate_all, over_space}; use crate::pathtype::{self, BracketDrag, over_bracket}; use crate::pie::{self, PieDrag}; use crate::rulerguide::GuideEdit; @@ -107,6 +107,8 @@ enum State { Pie(PieDrag), /// Dragging an Objects on Path group's Rotate All widget. RotateAll(RotateAllDrag), + /// Dragging an Objects on Path group's Space widget. + Space(SpaceDrag), /// Move or rotate a live mirror axis. Mirror(crate::mirror::MirrorDrag), /// Drag a grid repeat's row, column or corner handle or a spacing slider. @@ -642,7 +644,7 @@ impl Tool for SelectionTool { /// E during a marquee drag switches the marquee between the objects it touches and only the /// objects inside it. Escape during a drag (a move or an Alt-drag copy, a bounding-box scale or - /// rotation, a marquee, a corner widget, a pie widget, the Rotate All widget, the brackets of type on a path, a mirror + /// rotation, a marquee, a corner widget, a pie widget, the Rotate All or Space widget, the brackets of type on a path, a mirror /// axis, a grid repeat's handles, a ruler guide) cancels it: what it changed goes back, no copy /// is left, and the tool waits for the next press. With the pointer loaded with text, Escape /// drops the text. @@ -728,6 +730,7 @@ impl Tool for SelectionTool { State::Corner(c) => c.adjusting(), State::Pie(d) => d.adjusting(), State::RotateAll(d) => d.adjusting(), + State::Space(d) => d.adjusting(), State::Bracket(b) => b.adjusting(), State::Mirror(d) => d.adjusting(), State::GridRepeat(d) => d.adjusting(), @@ -891,6 +894,15 @@ impl Tool for SelectionTool { self.state = State::Idle; d.finish() } + (PointerKind::Drag, State::Space(mut d)) => { + let out = d.drag(cx, p, m.alt); + self.state = State::Space(d); + out + } + (PointerKind::Up, State::Space(d)) => { + self.state = State::Idle; + d.finish() + } (PointerKind::Drag, State::Bracket(mut b)) => { let out = b.drag(cx, p, m.cmd); self.state = State::Bracket(b); @@ -949,6 +961,7 @@ impl Tool for SelectionTool { State::Corner(c) => o.extend(c.overlays(cx)), State::Pie(d) => o.extend(d.overlays(cx)), State::RotateAll(d) => o.extend(d.overlays(cx)), + State::Space(d) => o.extend(d.overlays(cx)), State::Loaded { press: Some((start, cur)), .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))), _ => {} } @@ -997,6 +1010,7 @@ impl Tool for SelectionTool { State::Corner(_) => return Cursor::CornerRadius, State::Pie(_) => return Cursor::ArrowHollow, State::RotateAll(_) => return Cursor::Rotate, + State::Space(_) => return Cursor::ArrowHollow, State::Bracket(_) => return Cursor::PathBracket, State::Loaded { .. } => return Cursor::LoadedText, _ => {} @@ -1013,6 +1027,9 @@ impl Tool for SelectionTool { if over_rotate_all(cx, p) { return Cursor::Rotate; } + if over_space(cx, p) { + return Cursor::ArrowHollow; + } if over_bracket(cx, p) { return Cursor::PathBracket; } @@ -1355,7 +1372,7 @@ impl SelectionTool { self.state = State::Loaded { from, before: port == crate::text::Port::In, press: None }; return Some(vec![]); } - // 1. Live Corners widgets, pie widgets, the Rotate All widget and type on a path's brackets, then the + // 1. Live Corners widgets, pie widgets, the Rotate All and Space widgets and type on a path's brackets, then the // bounding-box handles of the current selection. if let Some(c) = CornerDrag::hit(cx, ev, false) { self.state = State::Corner(c); @@ -1369,6 +1386,10 @@ impl SelectionTool { self.state = State::RotateAll(d); return Some(vec![]); } + if let Some(d) = SpaceDrag::hit(cx, ev) { + self.state = State::Space(d); + return Some(vec![]); + } if let Some(b) = BracketDrag::hit(cx, ev) { self.state = State::Bracket(b); return Some(vec![]); diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index f8c9b95f5..b98e18648 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -298,18 +298,33 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: rotate_all_widget(p, xf, tool, &st.doc, &st.selection); } -/// The Rotate All widget (Selection and Objects on Path tools, one Objects on Path group -/// selected): joined by a line to the first object's attachment point. It stays while dragged. +/// The Rotate All and Space widgets (Selection and Objects on Path tools, one Objects on Path +/// group selected). The Rotate All widget is joined by a line to the first object's attachment +/// point. It stays while dragged. fn rotate_all_widget(p: &egui::Painter, xf: &Xf, tool: &str, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) { use vectorcraft_tools::objects_on_path::{ROTATE_ALL_RADIUS_PX, RotateAllWidget}; if !matches!(tool, "selection" | "objectsOnPath") { return; } - let Some(w) = RotateAllWidget::of(doc, selection, xf.zoom) else { return }; - let (on, c, color) = (xf.to_screen(w.pivot), xf.to_screen(w.at), c32(doc.layer_color(w.group))); + if let Some(w) = RotateAllWidget::of(doc, selection, xf.zoom) { + let (on, c, color) = (xf.to_screen(w.pivot), xf.to_screen(w.at), c32(doc.layer_color(w.group))); + p.line_segment([on, c], Stroke::new(1.0, color)); + p.circle_filled(c, ROTATE_ALL_RADIUS_PX, Color32::WHITE); + p.circle_stroke(c, ROTATE_ALL_RADIUS_PX, Stroke::new(1.0, color)); + } + space_widget(p, xf, doc, selection); +} + +/// The Space widget (beside the Rotate All widget, with two or more objects on the path): a square +/// joined by a line to the last object's attachment point. It stays while dragged. +fn space_widget(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) { + use vectorcraft_tools::objects_on_path::{SPACE_HALF_PX, SpaceWidget}; + let Some(w) = SpaceWidget::of(doc, selection, xf.zoom) else { return }; + let (on, c, color) = (xf.to_screen(w.last), xf.to_screen(w.at), c32(doc.layer_color(w.group))); + let r = egui::Rect::from_center_size(c, vec2(2.0 * SPACE_HALF_PX, 2.0 * SPACE_HALF_PX)); p.line_segment([on, c], Stroke::new(1.0, color)); - p.circle_filled(c, ROTATE_ALL_RADIUS_PX, Color32::WHITE); - p.circle_stroke(c, ROTATE_ALL_RADIUS_PX, Stroke::new(1.0, color)); + p.rect_filled(r, 0.0, Color32::WHITE); + p.rect_stroke(r, 0.0, Stroke::new(1.0, color), StrokeKind::Inside); } /// Record where the Type tool's IME candidate window goes this pass, if it has an editor. diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index efb608e74..0c9462818 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -4024,6 +4024,41 @@ fn a_selected_objects_on_path_group_shows_its_rotate_all_widget() { assert_eq!(widgets(&mut app), 0, "an object in the group, not the group"); } +/// A selected Objects on Path group of two or more objects shows its Space widget, a white square +/// beyond its last object, with the Selection and Objects on Path tools. +#[test] +fn a_selected_objects_on_path_group_shows_its_space_widget() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = |v: serde_json::Value| v["id"].as_u64().unwrap(); + let line = id(app.session.execute("shape.line", &json!({"x1": 20, "y1": 200, "x2": 380, "y2": 200})).unwrap()); + let a = id(app.session.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 20, "height": 20})).unwrap()); + let b = id(app.session.execute("shape.rectangle", &json!({"x": 80, "y": 40, "width": 20, "height": 20})).unwrap()); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let g = id(app.session.execute("object.objectsOnPath.attach", &json!({"path": line})).unwrap()); + let ctx = egui::Context::default(); + let widgets = |app: &mut VectorcraftApp| { + let side = 2.0 * vectorcraft_tools::objects_on_path::SPACE_HALF_PX; + shapes(app, &ctx) + .iter() + .filter(|s| matches!(s, Shape::Rect(r) if r.rect.width() == side && r.rect.height() == side && r.fill == Color32::WHITE)) + .count() + }; + app.select_tool("selection"); + assert_eq!(widgets(&mut app), 1); + app.select_tool("objectsOnPath"); + assert_eq!(widgets(&mut app), 1); + app.select_tool("directSelection"); + assert_eq!(widgets(&mut app), 0); + app.select_tool("selection"); + app.run("select.set", json!({"ids": [a]})).unwrap(); + assert_eq!(widgets(&mut app), 0, "an object in the group, not the group"); + // With one object on the path there is no space to set. + app.run("edit.clear", json!({"ids": [b]})).unwrap(); + app.run("select.set", json!({"ids": [g]})).unwrap(); + assert_eq!(widgets(&mut app), 0, "one object"); +} + /// The shapes Type → Show Hidden Characters draws in one frame, seen through `xf`. fn hidden_chars(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) -> Vec { let raw = egui::RawInput { screen_rect: Some(xf.rect), ..Default::default() }; diff --git a/docs/mcp.md b/docs/mcp.md index ab1a022a2..55a58ec79 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6055,12 +6055,22 @@ attaches nothing. 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.options {ids?, pivot?, rotate?, spacing?, start?}` changes Pivot, Rotate and Space, 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. +Space sets the distance along the path between neighboring objects' attachment points. `spacing` takes points, 0 or +more, or `"auto"`, the default, which spreads the objects evenly with the last one at the end of an open path. `start` +is the distance in points along the path from its first anchor to the first object's attachment point (default 0). A +`spacing` larger than the path has room for is drawn smaller: on an open path the last object stops at the end, and on +a closed path the objects go no more than once around. The value is kept, so lengthening the path again brings the +objects back to it. On a closed path, `start` counts around from the first anchor. Pivot, Rotate, moving the group and +saving the file keep the Space. Scaling, rotating or shearing the group keeps the first and last objects where the +transform takes them on the new path, and sets `spacing` and `start` to match; a value that cannot be kept that way +scales as much as the path's length. + In the desktop app, double-clicking the Objects on Path tool (`tool.options {tool: "objectsOnPath"}`) opens Objects on Path Tool Options (dialog `objectsOnPathOptions`): the nine-point Pivot grid and the Rotate field (°). Save sets the tool's options (`tool.setOption {tool: "objectsOnPath", values: {pivot, rotate}}`), which the objects attached next @@ -6075,10 +6085,20 @@ counter-clockwise on the page: the drag previews `object.objectsOnPath.options { group's Rotate plus that angle and keeps it as one undo step on release. Shift turns in steps of 15°, and Escape cancels the drag. +With two or more objects in the group, the same tools show the Space widget, a square handle beyond the group's last +object, ahead along the path and joined by a line to its attachment point. Dragging it moves the last object along the +path as far as the pointer moves along the path, and previews +`object.objectsOnPath.options {ids: [group], spacing, start}` and keeps it as one undo step on release. The first +object stays where it is and the others move toward or away from it; on an open path the last object goes no further +than the end. With Alt (Option on macOS) held, the objects spread from both ends toward or away from the center of an +open path, or of the first anchor of a closed path, up to the whole path. With measurement labels on, the distance +between the objects shows next to the pointer in the document's units. Escape cancels the drag. + ```json {"name":"run_command","arguments":{"command":"tool.setOption","params":{"tool":"objectsOnPath","values":{"pivot":7,"rotate":30}}}} {"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.options","params":{"spacing":24,"start":0}}} {"name":"run_command","arguments":{"command":"object.objectsOnPath.expand","params":{}}} ``` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 09e384295..ed3f6b6cc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -978,7 +978,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur When the object the Blend tool picked first is deleted or undone away before the second click, the next click on an object picks that object first instead of doing nothing. Regression tests: `blend_tool_picks_again_when_its_first_pick_is_gone` in `meshblend.rs` and `the_blend_tool_picks_again_when_its_first_pick_is_deleted` in `tests/blendopts.rs`. 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. With the Blend tool, Escape drops the object clicked first and stops the next click adding to the blend just made, so the next click picks a new first object; the document and the selection stay as they are. Regression tests: `escape_drops_the_blend_tools_first_pick` in `meshblend.rs` and `tests/blendopts.rs`. - 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`), and with Objects on Path groups selected the Properties panel's Objects on Path Options section shows them as a nine-point Pivot grid and a Rotate field with common angles, each change one undo step for every selected group. 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. With one Objects on Path group selected, the Selection and Objects on Path tools show the Rotate All widget beside its first object: dragging it turns every object about its attachment point by the angle the pointer turns, adding it to the group's Rotate in one undo step, in 15° steps with Shift ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). The Space, Move All and Select/Move widgets and Detach are missing. + 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`), and with Objects on Path groups selected the Properties panel's Objects on Path Options section shows them as a nine-point Pivot grid and a Rotate field with common angles, each change one undo step for every selected group. 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. With one Objects on Path group selected, the Selection and Objects on Path tools show the Rotate All widget beside its first object: dragging it turns every object about its attachment point by the angle the pointer turns, adding it to the group's Rotate in one undo step, in 15° steps with Shift ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). The Move All and Select/Move widgets and Detach are missing. + Objects on Path Space widget: with one Objects on Path group of two or more objects selected, the Selection and Objects on Path tools show a square handle beyond its last object; dragging it along the path, round its bends, sets the distance between the objects' attachment points in one undo step, the first object staying where it is, and with Alt (Option on macOS) the objects spread from both ends toward the center; the distance shows next to the pointer during the drag, in the document's units ([Rearrange and edit objects on a path](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html), [Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). The group keeps the distance (`object.objectsOnPath.options {spacing, start}`) through Pivot and Rotate changes, path edits, copies, saving and reopening, and scales it with the group; by default (`"auto"`) the objects are spread evenly as before. Evidence: `objects_on_path::tests::{space_sets_the_distance_between_the_stops, space_on_a_closed_path_wraps_at_the_first_anchor_and_stops_short_of_the_first_object}` in `crates/doc`, `tests::objects_on_path::space_sets_the_distance_between_the_objects_in_one_undo_step` in `crates/engine`, `objects_on_path::tests::dragging_the_space_widget_spaces_the_objects_from_the_first_one` in `crates/tools`. Double-clicking the Objects on Path tool opens Objects on Path Tool Options ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)): the nine-point Pivot grid and the Rotate field set the attachment point and angle of the objects attached next, and groups already attached keep theirs; Reset puts the center and 0° back. Save keeps the options across tool switches and saves them with the preferences (`tool.setOption {tool: "objectsOnPath"}`). - **Paint and appearance (M3):** - **CSS swatch libraries:** fractional linear and radial gradient stop positions are preserved, so nearby stops such as 12.5% and 12.890625% remain distinct instead of collapsing to 13%. Reference: [CSS Images 3 color stops](https://www.w3.org/TR/css-images-3/#color-stop-syntax). The original synthetic regression is `palette_io::tests::css_preserves_fractional_gradient_stop_positions` in `crates/color/src/palette_io.rs`. Scores and hours are unchanged.