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