mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 05:24:44 +00:00
Merge pull request #7246 from dotCooCoo/fix/objects-on-path-flatten-once
Objects on Path: each path is flattened once per placement or transform
This commit is contained in:
@@ -1205,6 +1205,13 @@ pub fn blend_step_count(a: &Node, b: &Node, spacing: BlendSpacing, len: f64) ->
|
||||
// Spine
|
||||
// =====================================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// How many times [`Spine::new`] has run on this thread, for tests that count how often a path
|
||||
/// is flattened.
|
||||
pub(crate) static SPINES_BUILT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// A spine (the first subpath of a path) flattened to a polyline, with cumulative lengths and the
|
||||
/// arc length at each of its anchors.
|
||||
pub struct Spine {
|
||||
@@ -1223,6 +1230,8 @@ impl Spine {
|
||||
/// arc lengths, and the arc length at each anchor. `None` when `path` has no subpath, or when
|
||||
/// the first one flattens to fewer than two points (so it has no length to walk).
|
||||
pub fn new(path: &PathData) -> Option<Self> {
|
||||
#[cfg(test)]
|
||||
SPINES_BUILT.with(|n| n.set(n.get() + 1));
|
||||
let sp = path.subpaths.first()?;
|
||||
let mut pts: Vec<Point> = Vec::new();
|
||||
let mut cum: Vec<f64> = Vec::new();
|
||||
|
||||
@@ -121,19 +121,22 @@ impl ObjectsOnPath {
|
||||
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));
|
||||
if !moves {
|
||||
let measured = paths.and_then(|(old, new)| Some((Measured::new(old)?, Measured::new(new)?)));
|
||||
let along = measured.as_ref().and_then(|(was, is)| {
|
||||
let s = is.len / was.len;
|
||||
(s.is_finite() && s > 0.0).then_some(s)
|
||||
});
|
||||
let scale = along.unwrap_or_else(|| a.determinant().abs().sqrt());
|
||||
if scale.is_finite() && scale > 0.0 {
|
||||
let scaled = |v: f64| Some(v * scale).filter(|s| s.is_finite()).unwrap_or(v);
|
||||
let moved = measured.as_ref().and_then(|(was, is)| self.moved_along(a, was, is, 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);
|
||||
for m in &mut self.members {
|
||||
@@ -150,11 +153,10 @@ impl ObjectsOnPath {
|
||||
/// 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<f64>, Option<f64>)> {
|
||||
let (was, is) = (Spine::new(old)?, Spine::new(new)?);
|
||||
let (len, closed) = path_length(old)?;
|
||||
let (new_len, _) = path_length(new)?;
|
||||
/// than two objects. `None` for a start that is not finite.
|
||||
fn moved_along(&self, a: Affine, old: &Measured, new: &Measured, scale: f64) -> Option<(Option<f64>, Option<f64>)> {
|
||||
let (was, is) = (&old.spine, &new.spine);
|
||||
let (len, closed, new_len) = (old.len, old.closed, new.len);
|
||||
if !self.start.is_finite() {
|
||||
return None;
|
||||
}
|
||||
@@ -230,10 +232,10 @@ pub fn is_objects_on_path(n: &Node) -> bool {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// (not a guide) whose first subpath has a length ([`path_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),
|
||||
NodeKind::Path { path, guide: false, .. } if path_length(path).is_some() => Some(path),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -292,11 +294,27 @@ pub fn upright_frame(n: &Node) -> Affine {
|
||||
}
|
||||
|
||||
/// The length of `path`'s first subpath and whether it is closed. `None` for a path without
|
||||
/// length.
|
||||
/// length, or with a length too large to be finite.
|
||||
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);
|
||||
(len.is_finite() && len > 1e-9).then_some((len, closed))
|
||||
Measured::new(path).map(|m| (m.len, m.closed))
|
||||
}
|
||||
|
||||
/// A path's first subpath flattened ([`Spine`]), with its length and whether it is closed, so that
|
||||
/// one flattening serves both measuring the path and finding points along it.
|
||||
struct Measured {
|
||||
spine: Spine,
|
||||
len: f64,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
impl Measured {
|
||||
/// `None` for a path without length, or with a length too large to be finite.
|
||||
fn new(path: &PathData) -> Option<Self> {
|
||||
let spine = Spine::new(path)?;
|
||||
let len = spine.length();
|
||||
let closed = path.subpaths.first().is_some_and(|s| s.closed);
|
||||
(len.is_finite() && len > 1e-9).then_some(Self { spine, len, closed })
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the first of `n` objects attaches along a path of length `len` (closed or not), and the
|
||||
@@ -341,18 +359,20 @@ fn wrap(s: f64, len: f64) -> f64 {
|
||||
/// (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<f64>, start: f64) -> Option<Vec<(Point, f64)>> {
|
||||
let spine = Spine::new(path)?;
|
||||
let (len, closed) = path_length(path)?;
|
||||
let Measured { spine, len, closed } = Measured::new(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.
|
||||
/// `None` for an `i` of `n` or more, without measuring the path.
|
||||
pub fn stop(path: &PathData, n: usize, spacing: Option<f64>, start: f64, i: usize) -> Option<(Point, f64)> {
|
||||
let spine = Spine::new(path)?;
|
||||
let (len, closed) = path_length(path)?;
|
||||
if i >= n {
|
||||
return None;
|
||||
}
|
||||
let Measured { spine, len, closed } = Measured::new(path)?;
|
||||
let (start, gap) = layout(len, closed, n, spacing, start);
|
||||
(i < n).then(|| spine.at_length(nth_length(len, closed, start, gap, i)))
|
||||
Some(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
|
||||
@@ -400,11 +420,15 @@ pub fn place(g: &mut Node, symbols: &SymbolBoxes) -> bool {
|
||||
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 {
|
||||
let Some(path) = children.iter().find(|c| c.id == path_id).filter(|c| matches!(c.kind, NodeKind::Path { .. })).cloned() else {
|
||||
g.extras_mut().objects_on_path = None;
|
||||
return true;
|
||||
};
|
||||
let path = attachable_path(path).cloned();
|
||||
// `stops` measures the path: on a path without length no object moves.
|
||||
let path = match &path.kind {
|
||||
NodeKind::Path { path, guide: false, .. } => Some(path),
|
||||
_ => None,
|
||||
};
|
||||
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();
|
||||
@@ -414,7 +438,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(), rec.spacing, rec.start)) {
|
||||
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 };
|
||||
@@ -709,6 +733,72 @@ mod tests {
|
||||
assert!((back.angle - 0.5).abs() < 1e-12, "{back:?}");
|
||||
}
|
||||
|
||||
/// What `f` returns, and how many times it flattened a path ([`Spine::new`]).
|
||||
fn flattenings<T>(f: impl FnOnce() -> T) -> (T, usize) {
|
||||
crate::blend::SPINES_BUILT.with(|n| n.set(0));
|
||||
let out = f();
|
||||
(out, crate::blend::SPINES_BUILT.with(|n| n.get()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stops_and_stop_flatten_the_path_once() {
|
||||
let open = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0));
|
||||
let closed = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
for path in [&open, &closed] {
|
||||
let (all, count) = flattenings(|| stops(path, 4, Some(40.0), 25.0));
|
||||
let all = all.unwrap();
|
||||
assert_eq!((all.len(), count), (4, 1));
|
||||
for (i, s) in all.iter().enumerate() {
|
||||
assert_eq!(flattenings(|| stop(path, 4, Some(40.0), 25.0, i)), (Some(*s), 1), "object {i}");
|
||||
}
|
||||
assert_eq!(flattenings(|| stop(path, 4, Some(40.0), 25.0, 4)), (None, 0), "an index past the last object");
|
||||
assert_eq!(flattenings(|| stop(path, 0, None, 0.0, 0)), (None, 0), "no objects");
|
||||
}
|
||||
let point = shapes::line(Point::ZERO, Point::ZERO);
|
||||
assert!(stops(&point, 2, None, 0.0).is_none() && stop(&point, 2, None, 0.0, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforming_the_group_flattens_each_path_once() {
|
||||
let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
let b = rect(3, Rect::new(20.0, 0.0, 30.0, 10.0));
|
||||
let level = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0));
|
||||
let stretch = Affine::scale_non_uniform(2.0, 1.0);
|
||||
let wide = level.transformed(stretch);
|
||||
let mut rec = ObjectsOnPath { spacing: Some(40.0), start: 10.0, ..ObjectsOnPath::new(NodeId(1), &[&a, &b], CENTER, 0.0) };
|
||||
let ((), count) = flattenings(|| rec.transform(stretch, Some((&level, &wide))));
|
||||
assert_eq!(count, 2, "the path before the transform and after it");
|
||||
assert!((rec.start - 20.0).abs() < 1e-9 && (rec.spacing.unwrap() - 80.0).abs() < 1e-9, "{rec:?}");
|
||||
let moved = level.transformed(Affine::translate((5.0, 5.0)));
|
||||
let ((), count) = flattenings(|| rec.transform(Affine::translate((5.0, 5.0)), Some((&level, &moved))));
|
||||
assert_eq!(count, 0, "a move leaves the distances along the path as they are");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placing_the_objects_flattens_the_path_once() {
|
||||
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 });
|
||||
g.extras_mut().objects_on_path = Some(Box::new(ObjectsOnPath::new(NodeId(1), &[&a, &b], CENTER, 0.0)));
|
||||
let empty = crate::Document::new(1.0, 1.0);
|
||||
let symbols = empty.symbol_boxes();
|
||||
assert_eq!(flattenings(|| place(&mut g, &symbols)), (true, 1));
|
||||
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))).abs() < 1e-9 && (x(NodeId(3)) - 300.0).abs() < 1e-9, "{} {}", x(NodeId(2)), x(NodeId(3)));
|
||||
}
|
||||
|
||||
/// A path whose length adds up past the largest finite number cannot be measured, so nothing
|
||||
/// attaches to it.
|
||||
#[test]
|
||||
fn a_path_too_long_to_measure_is_not_attachable() {
|
||||
let huge = Node::path(NodeId(1), shapes::line(Point::new(-1e308, 0.0), Point::new(1e308, 0.0)), Default::default());
|
||||
let NodeKind::Path { path, .. } = &huge.kind else { panic!("a path") };
|
||||
assert!(path_length(path).is_none() && stops(path, 2, None, 0.0).is_none());
|
||||
assert!(attachable_path(&huge).is_none());
|
||||
let square = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
assert!(attachable_path(&square).is_some());
|
||||
}
|
||||
|
||||
#[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));
|
||||
|
||||
@@ -1850,6 +1850,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
|
||||
- **Positions along a blend spine:** blend steps, keys spread along a spine, the blend widgets and Objects on Path find the spine segment for each arc length with a binary search over the anchors' arc lengths, so a spine of 4,096 segments takes at most 13 comparisons per position instead of one per segment before it. Points and tangents are unchanged, including at anchors shared by zero-length segments. Evidence: `blend::tests::{spine_lookup_compares_a_few_segment_ends, spine_lookup_matches_walking_the_segments}`.
|
||||
|
||||
- **Objects on Path measures its path once:** placing the objects, finding one object's place and transforming the group flatten each path once and use it both for the path's length and for the points along it, where they flattened it two or three times before; a request for an object past the last one returns nothing without flattening the path, and a move of the group flattens no path. A path whose length adds up past the largest finite number no longer counts as one objects can be attached to. Evidence: `objects_on_path::tests::{stops_and_stop_flatten_the_path_once, transforming_the_group_flattens_each_path_once, placing_the_objects_flattens_the_path_once, a_path_too_long_to_measure_is_not_attachable}` in `crates/doc`.
|
||||
|
||||
- **Blend, mesh and envelope numbers given as text:** `object.blend.make` and `object.blend.options` (`steps`, `distance`, `value`), `object.envelope.makeWithWarp`, `resetWithWarp` and `options` (`bend`, `h`, `v`, `horizontalDistortion`, `verticalDistortion`), `object.envelope.makeWithMesh` and `resetWithMesh` (`rows`, `cols`, `columns`), `object.envelope.options` (`fidelity`), `object.mesh.create` (`rows`, `cols`, `columns`, `highlight`) and `object.mesh.setPointColor` (`opacity`) refuse a value other than a number or null, such as `"8"`, and change nothing. `object.mesh.create` refuses an `at` that is not `[x, y]`.
|
||||
|
||||
- **Blend and envelope options given as text:** `object.blend.make` and `object.blend.options` (`smooth`), `object.blend.spine.moveAnchor` (`independent`) and `reshapeSegment` (`perpendicular`), `object.envelope.makeWithWarp`, `resetWithWarp` and `options` (`horizontal`), `object.envelope.resetWithMesh` (`maintainShape`), `object.envelope.options` (`antiAlias`, `distortAppearance`, `distortLinearGradients`, `distortPatternFills`) and `object.envelope.editContents` (`editing`) refuse a value other than true, false or null, such as `"false"`, and leave the document and its undo history as they were.
|
||||
|
||||
Reference in New Issue
Block a user