Skip to main content

rust_motion_paths/
bounding_box.rs

1//! Bounding box computation for SVG paths.
2
3use crate::parser::parse_path;
4use crate::point_at_length::get_point_at_length;
5use crate::types::{BoundingBox, Instruction, Point};
6
7#[derive(Debug)]
8struct BoundsTracker {
9    min_x: f64,
10    min_y: f64,
11    max_x: f64,
12    max_y: f64,
13    has_points: bool,
14}
15
16impl BoundsTracker {
17    fn new() -> Self {
18        Self {
19            min_x: f64::INFINITY,
20            min_y: f64::INFINITY,
21            max_x: f64::NEG_INFINITY,
22            max_y: f64::NEG_INFINITY,
23            has_points: false,
24        }
25    }
26
27    fn update(&mut self, x: f64, y: f64) {
28        if x.is_finite() && y.is_finite() {
29            self.min_x = self.min_x.min(x);
30            self.min_y = self.min_y.min(y);
31            self.max_x = self.max_x.max(x);
32            self.max_y = self.max_y.max(y);
33            self.has_points = true;
34        }
35    }
36
37    fn update_x(&mut self, x: f64) {
38        if x.is_finite() {
39            self.min_x = self.min_x.min(x);
40            self.max_x = self.max_x.max(x);
41            self.has_points = true;
42        }
43    }
44
45    fn update_y(&mut self, y: f64) {
46        if y.is_finite() {
47            self.min_y = self.min_y.min(y);
48            self.max_y = self.max_y.max(y);
49            self.has_points = true;
50        }
51    }
52
53    fn into_bounding_box(self) -> Option<BoundingBox> {
54        if !self.has_points {
55            None
56        } else {
57            Some(BoundingBox::new(
58                self.min_x,
59                self.min_y,
60                (self.max_x - self.min_x).max(0.0),
61                (self.max_y - self.min_y).max(0.0),
62            ))
63        }
64    }
65}
66
67/// Calculates the axis-aligned bounding box of an SVG path string.
68///
69/// Returns `None` if the path contains no points or cannot be parsed.
70pub fn get_bounding_box(path: &str) -> Option<BoundingBox> {
71    let instructions = parse_path(path).ok()?;
72    get_instructions_bounding_box(&instructions)
73}
74
75/// Calculates the bounding box of a slice of [`Instruction`]s.
76pub fn get_instructions_bounding_box(instructions: &[Instruction]) -> Option<BoundingBox> {
77    if instructions.is_empty() {
78        return None;
79    }
80
81    let mut tracker = BoundsTracker::new();
82    let mut curr = Point::new(0.0, 0.0);
83    let mut start_point = Point::new(0.0, 0.0);
84
85    for inst in instructions {
86        match inst {
87            Instruction::MoveTo { x, y } => {
88                tracker.update(*x, *y);
89                curr = Point::new(*x, *y);
90                start_point = curr;
91            }
92            Instruction::LineTo { x, y } => {
93                tracker.update(*x, *y);
94                curr = Point::new(*x, *y);
95            }
96            Instruction::ClosePath => {
97                curr = start_point;
98            }
99            Instruction::QuadCurveTo { x1, y1, x, y } => {
100                tracker.update(curr.x, curr.y);
101                tracker.update(*x, *y);
102
103                // Quadratic extrema: B'(t) = 0
104                // t = (P0 - P1) / (P0 - 2P1 + P2)
105                for (p0, p1, p2, is_x) in [(curr.x, *x1, *x, true), (curr.y, *y1, *y, false)] {
106                    let denom = p0 - 2.0 * p1 + p2;
107                    if denom.abs() > 1e-9 {
108                        let t = (p0 - p1) / denom;
109                        if (0.0..=1.0).contains(&t) {
110                            let val =
111                                (1.0 - t) * (1.0 - t) * p0 + 2.0 * (1.0 - t) * t * p1 + t * t * p2;
112                            if is_x {
113                                tracker.update_x(val);
114                            } else {
115                                tracker.update_y(val);
116                            }
117                        }
118                    }
119                }
120
121                curr = Point::new(*x, *y);
122            }
123            Instruction::CubicCurveTo {
124                x1,
125                y1,
126                x2,
127                y2,
128                x,
129                y,
130            } => {
131                tracker.update(curr.x, curr.y);
132                tracker.update(*x, *y);
133
134                // Cubic extrema: A t^2 + B t + C = 0
135                for (p0, p1, p2, p3, is_x) in
136                    [(curr.x, *x1, *x2, *x, true), (curr.y, *y1, *y2, *y, false)]
137                {
138                    let a = -p0 + 3.0 * p1 - 3.0 * p2 + p3;
139                    let b = 2.0 * (p0 - 2.0 * p1 + p2);
140                    let c = p1 - p0;
141
142                    let evaluate_t = |t: f64| -> f64 {
143                        let omt = 1.0 - t;
144                        omt * omt * omt * p0
145                            + 3.0 * omt * omt * t * p1
146                            + 3.0 * omt * t * t * p2
147                            + t * t * t * p3
148                    };
149
150                    if a.abs() < 1e-9 {
151                        if b.abs() > 1e-9 {
152                            let t = -c / b;
153                            if (0.0..=1.0).contains(&t) {
154                                let val = evaluate_t(t);
155                                if is_x {
156                                    tracker.update_x(val);
157                                } else {
158                                    tracker.update_y(val);
159                                }
160                            }
161                        }
162                    } else {
163                        let disc = b * b - 4.0 * a * c;
164                        if disc >= 0.0 {
165                            let sqrt_disc = disc.sqrt();
166                            let t1 = (-b - sqrt_disc) / (2.0 * a);
167                            let t2 = (-b + sqrt_disc) / (2.0 * a);
168                            if (0.0..=1.0).contains(&t1) {
169                                let val = evaluate_t(t1);
170                                if is_x {
171                                    tracker.update_x(val);
172                                } else {
173                                    tracker.update_y(val);
174                                }
175                            }
176                            if (0.0..=1.0).contains(&t2) {
177                                let val = evaluate_t(t2);
178                                if is_x {
179                                    tracker.update_x(val);
180                                } else {
181                                    tracker.update_y(val);
182                                }
183                            }
184                        }
185                    }
186                }
187
188                curr = Point::new(*x, *y);
189            }
190            Instruction::ArcTo { x, y, .. } => {
191                tracker.update(curr.x, curr.y);
192                tracker.update(*x, *y);
193                // Sample points along arc for accurate bbox
194                let single_arc_insts = vec![
195                    Instruction::MoveTo {
196                        x: curr.x,
197                        y: curr.y,
198                    },
199                    inst.clone(),
200                ];
201                for step in 1..8 {
202                    let progress = step as f64 / 8.0;
203                    let pt = get_point_at_length(
204                        &crate::parser::serialize_instructions(&single_arc_insts),
205                        progress * crate::length::get_instructions_length(&single_arc_insts),
206                    );
207                    tracker.update(pt.x, pt.y);
208                }
209                curr = Point::new(*x, *y);
210            }
211        }
212    }
213
214    tracker.into_bounding_box()
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn test_rect_bounding_box() {
223        let path = "M 10 20 L 110 20 L 110 70 L 10 70 Z";
224        let bbox = get_bounding_box(path).expect("bbox should exist");
225        assert!((bbox.x - 10.0).abs() < 1e-5);
226        assert!((bbox.y - 20.0).abs() < 1e-5);
227        assert!((bbox.width - 100.0).abs() < 1e-5);
228        assert!((bbox.height - 50.0).abs() < 1e-5);
229    }
230
231    #[test]
232    fn test_cubic_curve_bounding_box() {
233        // Curve bulging out beyond endpoints
234        let path = "M 0 0 C 50 100 150 100 200 0";
235        let bbox = get_bounding_box(path).expect("bbox should exist");
236        assert!((bbox.x - 0.0).abs() < 1e-5);
237        assert!((bbox.width - 200.0).abs() < 1e-5);
238        assert!(bbox.height > 50.0); // Curve bulges up towards 75
239    }
240}