//! DeckCraft geometry: units, shape transforms and preset shape generators. //! //! The document model keeps positions in points (1/72 inch) as `f64`. Office Open XML stores EMU //! (English Metric Units, 12 700 per point); [`emu_to_pt`] and [`pt_to_emu`] convert losslessly for //! integer EMU values, so files round-trip exactly. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] pub mod morph; pub mod preset; pub mod squircle; pub mod warp; pub use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as KurboShape, Size, Vec2}; use serde::{Deserialize, Serialize}; /// EMU per point. pub const EMU_PER_PT: f64 = 12_700.0; /// EMU per inch. pub const EMU_PER_IN: f64 = 914_400.0; /// EMU per centimetre. pub const EMU_PER_CM: f64 = 360_000.0; pub fn emu_to_pt(emu: i64) -> f64 { emu as f64 / EMU_PER_PT } /// Points to EMU, rounded. Non-finite input becomes 0; huge values saturate. pub fn pt_to_emu(pt: f64) -> i64 { if !pt.is_finite() { return 0; } let v = (pt * EMU_PER_PT).round(); if v >= i64::MAX as f64 { i64::MAX } else if v <= i64::MIN as f64 { i64::MIN } else { v as i64 } } /// Display units for measurements (inches on US English systems, centimetres elsewhere). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Unit { #[default] Inches, Centimeters, Points, Pixels, } impl Unit { pub fn points_per_unit(self) -> f64 { match self { Unit::Inches => 72.0, Unit::Centimeters => 72.0 / 2.54, Unit::Points => 1.0, Unit::Pixels => 0.75, } } pub fn suffix(self) -> &'static str { match self { Unit::Inches => "\"", Unit::Centimeters => " cm", Unit::Points => " pt", Unit::Pixels => " px", } } /// `2.08"` style text for a length in points. pub fn format(self, pt: f64) -> String { let v = pt / self.points_per_unit(); let s = format!("{v:.2}"); format!("{s}{}", self.suffix()) } /// Parses `2.5`, `2.5"`, `2.5 in`, `6 cm`, `30 pt`, `40px`, `5mm` into points. A bare number uses `self`. pub fn parse(self, text: &str) -> Option { let t = text.trim().to_ascii_lowercase(); let (num, unit) = split_number(&t)?; let ppu = match unit.trim() { "" => self.points_per_unit(), "\"" | "in" | "inch" | "inches" => 72.0, "cm" => 72.0 / 2.54, "mm" => 72.0 / 25.4, "pt" | "pts" => 1.0, "px" => 0.75, _ => return None, }; let v = num * ppu; v.is_finite().then_some(v) } } fn split_number(t: &str) -> Option<(f64, &str)> { let end = t.char_indices().find(|(_, c)| !(c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+')).map(|(i, _)| i).unwrap_or(t.len()); let n: f64 = t.get(..end)?.parse().ok()?; Some((n, t.get(end..).unwrap_or(""))) } /// A shape's placement on its slide (or in its group's child space): offset, extent, rotation /// (degrees clockwise) and flips. Rotation and flips are about the centre. #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Xfrm { pub x: f64, pub y: f64, pub w: f64, pub h: f64, #[serde(skip_serializing_if = "is_zero")] pub rot: f64, #[serde(skip_serializing_if = "std::ops::Not::not")] pub flip_h: bool, #[serde(skip_serializing_if = "std::ops::Not::not")] pub flip_v: bool, } fn is_zero(v: &f64) -> bool { *v == 0.0 } impl Xfrm { pub fn new(x: f64, y: f64, w: f64, h: f64) -> Self { Xfrm { x, y, w, h, ..Default::default() } } pub fn rect(&self) -> Rect { Rect::new(self.x, self.y, self.x + self.w, self.y + self.h) } pub fn center(&self) -> Point { Point::new(self.x + self.w / 2.0, self.y + self.h / 2.0) } /// Local shape space (0..w, 0..h) → parent space, applying flips and rotation about the centre. pub fn affine(&self) -> Affine { let c = Vec2::new(self.w / 2.0, self.h / 2.0); let sx = if self.flip_h { -1.0 } else { 1.0 }; let sy = if self.flip_v { -1.0 } else { 1.0 }; Affine::translate(Vec2::new(self.x, self.y) + c) * Affine::rotate(self.rot.to_radians()) * Affine::scale_non_uniform(sx, sy) * Affine::translate(-c) } /// Axis-aligned bounds of the rotated box in parent space. pub fn bounds(&self) -> Rect { let a = self.affine(); let pts = [Point::new(0.0, 0.0), Point::new(self.w, 0.0), Point::new(self.w, self.h), Point::new(0.0, self.h)].map(|p| a * p); let mut r = Rect::from_points(pts[0], pts[1]); r = r.union_pt(pts[2]).union_pt(pts[3]); r } /// True when no dimension is NaN/inf. pub fn is_finite(&self) -> bool { [self.x, self.y, self.w, self.h, self.rot].iter().all(|v| v.is_finite()) } /// Rotation normalised to 0..360. pub fn rot_norm(&self) -> f64 { let r = self.rot % 360.0; if r < 0.0 { r + 360.0 } else { r } } } /// Maps a group's child coordinate space (`ch_off`, `ch_ext`) onto the group's own box. pub fn group_child_affine(group: &Xfrm, ch: Rect) -> Affine { let sx = if ch.width().abs() > 1e-9 { group.w / ch.width() } else { 1.0 }; let sy = if ch.height().abs() > 1e-9 { group.h / ch.height() } else { 1.0 }; group.affine() * Affine::scale_non_uniform(sx, sy) * Affine::translate(-ch.origin().to_vec2()) } /// Distance from `p` to the segment `a`–`b`. pub fn dist_to_segment(p: Point, a: Point, b: Point) -> f64 { let ab = b - a; let len2 = ab.hypot2(); if len2 < 1e-12 { return (p - a).hypot(); } let t = ((p - a).dot(ab) / len2).clamp(0.0, 1.0); (p - (a + ab * t)).hypot() } /// Is `p` within `tol` of the outline of `path` (flattened)? pub fn near_path(path: &BezPath, p: Point, tol: f64) -> bool { let mut hit = false; let mut start = Point::ZERO; let mut last = Point::ZERO; kurbo::flatten(path.iter(), 0.25, |el| { if hit { return; } match el { PathEl::MoveTo(q) => { start = q; last = q; } PathEl::LineTo(q) => { if dist_to_segment(p, last, q) <= tol { hit = true; } last = q; } PathEl::ClosePath => { if dist_to_segment(p, last, start) <= tol { hit = true; } last = start; } _ => {} } }); hit } /// Snap `v` to the nearest multiple of `step` (step <= 0 returns `v`). pub fn snap(v: f64, step: f64) -> f64 { if step > 0.0 && step.is_finite() { (v / step).round() * step } else { v } } /// Flatten a path to move/line/close elements (tolerance 0.2). pub fn preset_flatten(path: &BezPath, f: &mut dyn FnMut(PathEl)) { kurbo::flatten(path.iter(), 0.2, f); } #[cfg(test)] mod tests { use super::*; #[test] fn emu_roundtrip_is_exact() { for emu in [0i64, 1, 12_699, 12_700, 914_400, 12_192_000, 6_858_000, -5, 123_456_789] { assert_eq!(pt_to_emu(emu_to_pt(emu)), emu); } assert_eq!(pt_to_emu(f64::NAN), 0); assert_eq!(pt_to_emu(f64::INFINITY), 0); } #[test] fn unit_parse_and_format() { assert_eq!(Unit::Inches.parse("2"), Some(144.0)); assert_eq!(Unit::Inches.parse("2\""), Some(144.0)); assert!((Unit::Inches.parse("2.54 cm").unwrap() - 72.0).abs() < 1e-9); assert_eq!(Unit::Inches.parse("36pt"), Some(36.0)); assert_eq!(Unit::Inches.parse("abc"), None); assert_eq!(Unit::Inches.parse(""), None); assert_eq!(Unit::Inches.format(150.0), "2.08\""); } #[test] fn xfrm_affine_rotation_about_center() { let x = Xfrm { x: 10.0, y: 20.0, w: 100.0, h: 50.0, rot: 90.0, ..Default::default() }; let c = x.affine() * Point::new(50.0, 25.0); assert!((c.x - 60.0).abs() < 1e-9 && (c.y - 45.0).abs() < 1e-9); let b = x.bounds(); assert!((b.width() - 50.0).abs() < 1e-9 && (b.height() - 100.0).abs() < 1e-9); } #[test] fn flips_mirror_local_space() { let x = Xfrm { x: 0.0, y: 0.0, w: 100.0, h: 50.0, flip_h: true, ..Default::default() }; let p = x.affine() * Point::new(0.0, 0.0); assert!((p.x - 100.0).abs() < 1e-9 && p.y.abs() < 1e-9); } #[test] fn group_child_mapping() { let g = Xfrm::new(100.0, 100.0, 200.0, 100.0); let a = group_child_affine(&g, Rect::new(0.0, 0.0, 100.0, 50.0)); let p = a * Point::new(100.0, 50.0); assert!((p.x - 300.0).abs() < 1e-9 && (p.y - 200.0).abs() < 1e-9); } #[test] fn segment_distance() { assert!((dist_to_segment(Point::new(5.0, 5.0), Point::new(0.0, 0.0), Point::new(10.0, 0.0)) - 5.0).abs() < 1e-9); assert!((dist_to_segment(Point::new(0.0, 3.0), Point::new(0.0, 0.0), Point::new(0.0, 0.0)) - 3.0).abs() < 1e-9); } }