Skip to main content

motion_content/vector/
effects.rs

1//! Handwriting-style write-on effects for vector trees.
2
3use super::*;
4
5/// Shared cumulative-length handwriting and fill handoff settings.
6#[derive(Clone, Copy, Debug)]
7pub struct WriteOptions {
8    /// Temporary outline thickness in tree coordinates (not font units).
9    pub width: f64,
10    /// Fraction of progress reserved for drawing contours; the remainder hands
11    /// over to the original fill/stroke/images. Must be strictly between 0 and 1.
12    pub outline_fraction: f64,
13}
14
15impl Default for WriteOptions {
16    fn default() -> Self {
17        Self {
18            width: 1.0,
19            outline_fraction: 0.8,
20        }
21    }
22}
23
24impl VectorTree {
25    /// Draws paths in source reading order by their cumulative transformed arc
26    /// length, then fades temporary contours into the original artwork. Endpoints
27    /// are exact; finite progress clamps to 0..=1. Noninvertible writing matrices
28    /// and existing curve-stroke budget violations fail explicitly.
29    pub fn write(&self, progress: f64, options: WriteOptions) -> Result<Node, ValidationError> {
30        self.write_mapped(progress, options, &|_, node| node)
31    }
32
33    /// Applies handwriting while mapping original vector addresses to authored nodes.
34    pub fn write_mapped(
35        &self,
36        progress: f64,
37        options: WriteOptions,
38        map: &impl Fn(&[usize], Node) -> Node,
39    ) -> Result<Node, ValidationError> {
40        if !progress.is_finite()
41            || !options.width.is_finite()
42            || options.width <= 0.0
43            || !options.outline_fraction.is_finite()
44            || !(0.0..1.0).contains(&options.outline_fraction)
45            || options.outline_fraction == 0.0
46        {
47            return Err(invalid(
48                "write",
49                "Require finite progress, positive width and outline fraction in (0,1)",
50            ));
51        }
52        let progress = progress.clamp(0.0, 1.0);
53        if progress == 1.0 {
54            return Ok(self.node_mapped(map));
55        }
56        let handoff = ((progress - options.outline_fraction) / (1.0 - options.outline_fraction))
57            .clamp(0.0, 1.0);
58        let mut paths = Vec::new();
59        let mut total = 0.0;
60        for leaf in self.leaves() {
61            if let VectorItem::Path(path) = leaf.item
62                && (path.fill.is_some() || path.stroke.is_some())
63            {
64                let world = path.path.transformed(&leaf.transform)?;
65                let length = world.try_length()?;
66                total += length;
67                if !total.is_finite() {
68                    return Err(invalid("length", "Accumulated vector length overflow"));
69                }
70                paths.push((leaf, world, length));
71            }
72        }
73        let mut remaining = total * (progress / options.outline_fraction).min(1.0);
74        let mut drawings = BTreeMap::new();
75        for (leaf, world, length) in paths {
76            let amount = if length == 0.0 {
77                0.0
78            } else {
79                (remaining / length).clamp(0.0, 1.0)
80            };
81            remaining = (remaining - length).max(0.0);
82            let VectorItem::Path(path) = leaf.item else {
83                unreachable!()
84            };
85            let mut children = Vec::new();
86            if amount > 0.0 && handoff < 1.0 {
87                let inverse = leaf.transform.inverse().ok_or_else(|| {
88                    invalid(
89                        "transform",
90                        "Writing requires an invertible local-to-tree matrix",
91                    )
92                })?;
93                let outline = StrokeStyle {
94                    width: options.width,
95                    cap: motion_core::LineCap::Round,
96                    join: motion_core::LineJoin::Round,
97                }
98                .outline(&world.try_trim(0.0, amount)?, "vector_tree.write")?
99                .transformed(&inverse)?;
100                let paint = path
101                    .stroke
102                    .as_ref()
103                    .map(|(_, paint)| paint)
104                    .or_else(|| path.fill.as_ref().map(|(paint, _)| paint))
105                    .expect("painted path")
106                    .clone();
107                children
108                    .push(Node::from(VectorPath::filled(outline, paint)).opacity(1.0 - handoff));
109            }
110            if handoff > 0.0 {
111                children.push(Node::from(path.clone()).opacity(handoff));
112            }
113            drawings.insert(leaf.address, Node::group(children));
114        }
115
116        fn build(
117            group: &VectorGroup,
118            address: &mut Vec<usize>,
119            drawings: &BTreeMap<Vec<usize>, Node>,
120            handoff: f64,
121            map: &impl Fn(&[usize], Node) -> Node,
122        ) -> Node {
123            let children = group
124                .children
125                .iter()
126                .enumerate()
127                .map(|(index, item)| {
128                    address.push(index);
129                    let node = match item {
130                        VectorItem::Group(child) => build(child, address, drawings, handoff, map),
131                        VectorItem::Path(_) => {
132                            drawings.get(address).cloned().unwrap_or_else(Node::empty)
133                        }
134                        VectorItem::Image(_) => item_node(item).opacity(handoff),
135                    };
136                    let node = if matches!(item, VectorItem::Group(_)) {
137                        node
138                    } else {
139                        map(address, node)
140                    };
141                    address.pop();
142                    node
143                })
144                .collect::<Vec<_>>();
145            map(address, group.decorate(children))
146        }
147        let mut node = build(self.root(), &mut Vec::new(), &drawings, handoff, map);
148        if let Some(baseline) = self.data.baseline {
149            node = Node::group([node, Node::from(TextBounds(self.view_box()))])
150                .with_text_metrics(baseline, self.view_box());
151        }
152        Ok(node.pivot(motion_core::Pivot::Point(
153            self.node().measure(ValidationLimits::default())?.center(),
154        )))
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use motion_core::{Color, PixelSize, RasterLimits, Size};
162    use motion_render::RenderPrepare;
163
164    fn pixels(node: Node) -> motion_core::Result<motion_render::LinearFrame> {
165        Ok(node
166            .compile(
167                Size::new(16.0, 8.0),
168                PixelSize::new(16, 8),
169                ValidationLimits::default(),
170            )?
171            .prepare(ValidationLimits::default(), RasterLimits::default())?
172            .render_linear(RasterLimits::default())?)
173    }
174
175    #[test]
176    fn writing_uses_total_world_length_then_hands_off_to_exact_original() -> motion_core::Result<()>
177    {
178        let tree = VectorTree::new(
179            Rect::new(0.0, 0.0, 16.0, 8.0),
180            VectorGroup {
181                children: vec![
182                    VectorItem::Path(VectorPath::filled(
183                        Path2D::from_rect(Rect::new(1.0, 1.0, 5.0, 5.0))?,
184                        Color::WHITE,
185                    )),
186                    VectorItem::Path(VectorPath::filled(
187                        Path2D::from_rect(Rect::new(7.0, 1.0, 15.0, 5.0))?,
188                        Color::rgb(255, 0, 0),
189                    )),
190                ],
191                ..Default::default()
192            },
193        )?;
194        let options = WriteOptions::default();
195        let early = pixels(tree.write(0.8 * 16.0 / 40.0, options)?)?;
196        assert!(early.pixel(1, 1).unwrap()[3] > 0.0);
197        assert_eq!(early.pixel(3, 3), Some([0.0; 4]));
198        assert_eq!(early.pixel(7, 1), Some([0.0; 4]));
199        assert!(pixels(tree.write(0.9, options)?)?.pixel(3, 3).unwrap()[3] > 0.0);
200        assert_eq!(
201            pixels(tree.write(1.0, options)?)?.pixels(),
202            pixels(tree.node())?.pixels()
203        );
204        assert!(
205            pixels(tree.write(0.0, options)?)?
206                .pixels()
207                .iter()
208                .all(|pixel| *pixel == [0.0; 4])
209        );
210        assert!(tree.write(f64::NAN, options).is_err());
211        Ok(())
212    }
213}