Skip to main content

deckcraft_geom/
lib.rs

1//! DeckCraft geometry: units, shape transforms and preset shape generators.
2//!
3//! The document model keeps positions in points (1/72 inch) as `f64`. Office Open XML stores EMU
4//! (English Metric Units, 12 700 per point); [`emu_to_pt`] and [`pt_to_emu`] convert losslessly for
5//! integer EMU values, so files round-trip exactly.
6#![deny(
7    clippy::unwrap_used,
8    clippy::expect_used,
9    clippy::panic,
10    clippy::unimplemented,
11    clippy::todo,
12    clippy::unreachable
13)]
14#![forbid(unsafe_code)]
15
16pub mod morph;
17pub mod preset;
18pub mod warp;
19
20pub use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as KurboShape, Size, Vec2};
21use serde::{Deserialize, Serialize};
22
23/// EMU per point.
24pub const EMU_PER_PT: f64 = 12_700.0;
25/// EMU per inch.
26pub const EMU_PER_IN: f64 = 914_400.0;
27/// EMU per centimetre.
28pub const EMU_PER_CM: f64 = 360_000.0;
29
30pub fn emu_to_pt(emu: i64) -> f64 {
31    emu as f64 / EMU_PER_PT
32}
33
34/// Points to EMU, rounded. Non-finite input becomes 0; huge values saturate.
35pub fn pt_to_emu(pt: f64) -> i64 {
36    if !pt.is_finite() {
37        return 0;
38    }
39    let v = (pt * EMU_PER_PT).round();
40    if v >= i64::MAX as f64 {
41        i64::MAX
42    } else if v <= i64::MIN as f64 {
43        i64::MIN
44    } else {
45        v as i64
46    }
47}
48
49/// Display units for measurements (inches on US English systems, centimetres elsewhere).
50#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "camelCase")]
52pub enum Unit {
53    #[default]
54    Inches,
55    Centimeters,
56    Points,
57    Pixels,
58}
59
60impl Unit {
61    pub fn points_per_unit(self) -> f64 {
62        match self {
63            Unit::Inches => 72.0,
64            Unit::Centimeters => 72.0 / 2.54,
65            Unit::Points => 1.0,
66            Unit::Pixels => 0.75,
67        }
68    }
69    pub fn suffix(self) -> &'static str {
70        match self {
71            Unit::Inches => "\"",
72            Unit::Centimeters => " cm",
73            Unit::Points => " pt",
74            Unit::Pixels => " px",
75        }
76    }
77    /// `2.08"` style text for a length in points.
78    pub fn format(self, pt: f64) -> String {
79        let v = pt / self.points_per_unit();
80        let s = format!("{v:.2}");
81        format!("{s}{}", self.suffix())
82    }
83    /// Parses `2.5`, `2.5"`, `2.5 in`, `6 cm`, `30 pt`, `40px`, `5mm` into points. A bare number uses `self`.
84    pub fn parse(self, text: &str) -> Option<f64> {
85        let t = text.trim().to_ascii_lowercase();
86        let (num, unit) = split_number(&t)?;
87        let ppu = match unit.trim() {
88            "" => self.points_per_unit(),
89            "\"" | "in" | "inch" | "inches" => 72.0,
90            "cm" => 72.0 / 2.54,
91            "mm" => 72.0 / 25.4,
92            "pt" | "pts" => 1.0,
93            "px" => 0.75,
94            _ => return None,
95        };
96        let v = num * ppu;
97        v.is_finite().then_some(v)
98    }
99}
100
101fn split_number(t: &str) -> Option<(f64, &str)> {
102    let end = t
103        .char_indices()
104        .find(|(_, c)| !(c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+'))
105        .map(|(i, _)| i)
106        .unwrap_or(t.len());
107    let n: f64 = t.get(..end)?.parse().ok()?;
108    Some((n, t.get(end..).unwrap_or("")))
109}
110
111/// A shape's placement on its slide (or in its group's child space): offset, extent, rotation
112/// (degrees clockwise) and flips. Rotation and flips are about the centre.
113#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
114#[serde(default, rename_all = "camelCase")]
115pub struct Xfrm {
116    pub x: f64,
117    pub y: f64,
118    pub w: f64,
119    pub h: f64,
120    #[serde(skip_serializing_if = "is_zero")]
121    pub rot: f64,
122    #[serde(skip_serializing_if = "std::ops::Not::not")]
123    pub flip_h: bool,
124    #[serde(skip_serializing_if = "std::ops::Not::not")]
125    pub flip_v: bool,
126}
127
128fn is_zero(v: &f64) -> bool {
129    *v == 0.0
130}
131
132impl Xfrm {
133    pub fn new(x: f64, y: f64, w: f64, h: f64) -> Self {
134        Xfrm {
135            x,
136            y,
137            w,
138            h,
139            ..Default::default()
140        }
141    }
142    pub fn rect(&self) -> Rect {
143        Rect::new(self.x, self.y, self.x + self.w, self.y + self.h)
144    }
145    pub fn center(&self) -> Point {
146        Point::new(self.x + self.w / 2.0, self.y + self.h / 2.0)
147    }
148    /// Local shape space (0..w, 0..h) → parent space, applying flips and rotation about the centre.
149    pub fn affine(&self) -> Affine {
150        let c = Vec2::new(self.w / 2.0, self.h / 2.0);
151        let sx = if self.flip_h { -1.0 } else { 1.0 };
152        let sy = if self.flip_v { -1.0 } else { 1.0 };
153        Affine::translate(Vec2::new(self.x, self.y) + c)
154            * Affine::rotate(self.rot.to_radians())
155            * Affine::scale_non_uniform(sx, sy)
156            * Affine::translate(-c)
157    }
158    /// Axis-aligned bounds of the rotated box in parent space.
159    pub fn bounds(&self) -> Rect {
160        let a = self.affine();
161        let pts = [
162            Point::new(0.0, 0.0),
163            Point::new(self.w, 0.0),
164            Point::new(self.w, self.h),
165            Point::new(0.0, self.h),
166        ]
167        .map(|p| a * p);
168        let mut r = Rect::from_points(pts[0], pts[1]);
169        r = r.union_pt(pts[2]).union_pt(pts[3]);
170        r
171    }
172    /// True when no dimension is NaN/inf.
173    pub fn is_finite(&self) -> bool {
174        [self.x, self.y, self.w, self.h, self.rot]
175            .iter()
176            .all(|v| v.is_finite())
177    }
178    /// Rotation normalised to 0..360.
179    pub fn rot_norm(&self) -> f64 {
180        let r = self.rot % 360.0;
181        if r < 0.0 { r + 360.0 } else { r }
182    }
183}
184
185/// Maps a group's child coordinate space (`ch_off`, `ch_ext`) onto the group's own box.
186pub fn group_child_affine(group: &Xfrm, ch: Rect) -> Affine {
187    let sx = if ch.width().abs() > 1e-9 {
188        group.w / ch.width()
189    } else {
190        1.0
191    };
192    let sy = if ch.height().abs() > 1e-9 {
193        group.h / ch.height()
194    } else {
195        1.0
196    };
197    group.affine() * Affine::scale_non_uniform(sx, sy) * Affine::translate(-ch.origin().to_vec2())
198}
199
200/// Distance from `p` to the segment `a`–`b`.
201pub fn dist_to_segment(p: Point, a: Point, b: Point) -> f64 {
202    let ab = b - a;
203    let len2 = ab.hypot2();
204    if len2 < 1e-12 {
205        return (p - a).hypot();
206    }
207    let t = ((p - a).dot(ab) / len2).clamp(0.0, 1.0);
208    (p - (a + ab * t)).hypot()
209}
210
211/// Is `p` within `tol` of the outline of `path` (flattened)?
212pub fn near_path(path: &BezPath, p: Point, tol: f64) -> bool {
213    let mut hit = false;
214    let mut start = Point::ZERO;
215    let mut last = Point::ZERO;
216    kurbo::flatten(path.iter(), 0.25, |el| {
217        if hit {
218            return;
219        }
220        match el {
221            PathEl::MoveTo(q) => {
222                start = q;
223                last = q;
224            }
225            PathEl::LineTo(q) => {
226                if dist_to_segment(p, last, q) <= tol {
227                    hit = true;
228                }
229                last = q;
230            }
231            PathEl::ClosePath => {
232                if dist_to_segment(p, last, start) <= tol {
233                    hit = true;
234                }
235                last = start;
236            }
237            _ => {}
238        }
239    });
240    hit
241}
242
243/// Snap `v` to the nearest multiple of `step` (step <= 0 returns `v`).
244pub fn snap(v: f64, step: f64) -> f64 {
245    if step > 0.0 && step.is_finite() {
246        (v / step).round() * step
247    } else {
248        v
249    }
250}
251
252/// Flatten a path to move/line/close elements (tolerance 0.2).
253pub fn preset_flatten(path: &BezPath, f: &mut dyn FnMut(PathEl)) {
254    kurbo::flatten(path.iter(), 0.2, f);
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn emu_roundtrip_is_exact() {
263        for emu in [
264            0i64,
265            1,
266            12_699,
267            12_700,
268            914_400,
269            12_192_000,
270            6_858_000,
271            -5,
272            123_456_789,
273        ] {
274            assert_eq!(pt_to_emu(emu_to_pt(emu)), emu);
275        }
276        assert_eq!(pt_to_emu(f64::NAN), 0);
277        assert_eq!(pt_to_emu(f64::INFINITY), 0);
278    }
279
280    #[test]
281    fn unit_parse_and_format() {
282        assert_eq!(Unit::Inches.parse("2"), Some(144.0));
283        assert_eq!(Unit::Inches.parse("2\""), Some(144.0));
284        assert!((Unit::Inches.parse("2.54 cm").unwrap() - 72.0).abs() < 1e-9);
285        assert_eq!(Unit::Inches.parse("36pt"), Some(36.0));
286        assert_eq!(Unit::Inches.parse("abc"), None);
287        assert_eq!(Unit::Inches.parse(""), None);
288        assert_eq!(Unit::Inches.format(150.0), "2.08\"");
289    }
290
291    #[test]
292    fn xfrm_affine_rotation_about_center() {
293        let x = Xfrm {
294            x: 10.0,
295            y: 20.0,
296            w: 100.0,
297            h: 50.0,
298            rot: 90.0,
299            ..Default::default()
300        };
301        let c = x.affine() * Point::new(50.0, 25.0);
302        assert!((c.x - 60.0).abs() < 1e-9 && (c.y - 45.0).abs() < 1e-9);
303        let b = x.bounds();
304        assert!((b.width() - 50.0).abs() < 1e-9 && (b.height() - 100.0).abs() < 1e-9);
305    }
306
307    #[test]
308    fn flips_mirror_local_space() {
309        let x = Xfrm {
310            x: 0.0,
311            y: 0.0,
312            w: 100.0,
313            h: 50.0,
314            flip_h: true,
315            ..Default::default()
316        };
317        let p = x.affine() * Point::new(0.0, 0.0);
318        assert!((p.x - 100.0).abs() < 1e-9 && p.y.abs() < 1e-9);
319    }
320
321    #[test]
322    fn group_child_mapping() {
323        let g = Xfrm::new(100.0, 100.0, 200.0, 100.0);
324        let a = group_child_affine(&g, Rect::new(0.0, 0.0, 100.0, 50.0));
325        let p = a * Point::new(100.0, 50.0);
326        assert!((p.x - 300.0).abs() < 1e-9 && (p.y - 200.0).abs() < 1e-9);
327    }
328
329    #[test]
330    fn segment_distance() {
331        assert!(
332            (dist_to_segment(
333                Point::new(5.0, 5.0),
334                Point::new(0.0, 0.0),
335                Point::new(10.0, 0.0)
336            ) - 5.0)
337                .abs()
338                < 1e-9
339        );
340        assert!(
341            (dist_to_segment(
342                Point::new(0.0, 3.0),
343                Point::new(0.0, 0.0),
344                Point::new(0.0, 0.0)
345            ) - 3.0)
346                .abs()
347                < 1e-9
348        );
349    }
350}