diff --git a/crates/drawingml/src/custgeom.rs b/crates/drawingml/src/custgeom.rs index c4ac5d331..d886d30b9 100644 --- a/crates/drawingml/src/custgeom.rs +++ b/crates/drawingml/src/custgeom.rs @@ -28,6 +28,48 @@ pub struct CustomPath { pub stroke: bool, } +impl CustomPath { + /// A path from a `kurbo` path given in a `w` × `h` box (points), its coordinates kept as + /// they are. Quadratic segments become cubics: not every reader handles `a:quadBezTo`. + pub fn from_bez(bp: &kurbo::BezPath, w: f64, h: f64, filled: bool) -> CustomPath { + let mut d = String::new(); + let mut last = kurbo::Point::ZERO; + let mut put = |c: char, pts: &[kurbo::Point]| { + if !d.is_empty() { + d.push(' '); + } + d.push(c); + for p in pts { + let _ = write!(d, " {} {}", finite(p.x), finite(p.y)); + } + }; + for el in bp.elements() { + match *el { + kurbo::PathEl::MoveTo(a) => { + put('M', &[a]); + last = a; + } + kurbo::PathEl::LineTo(a) => { + put('L', &[a]); + last = a; + } + kurbo::PathEl::QuadTo(a, b) => { + let c1 = last + (a - last) * (2.0 / 3.0); + let c2 = b + (a - b) * (2.0 / 3.0); + put('C', &[c1, c2, b]); + last = b; + } + kurbo::PathEl::CurveTo(a, b, c) => { + put('C', &[a, b, c]); + last = c; + } + kurbo::PathEl::ClosePath => put('Z', &[]), + } + } + CustomPath { w, h, d, fill: if filled { FillMode::Norm } else { FillMode::None }, stroke: true } + } +} + impl Default for CustomPath { fn default() -> Self { CustomPath { w: 0.0, h: 0.0, d: String::new(), fill: FillMode::Norm, stroke: true } @@ -651,4 +693,20 @@ mod tests { let d = format!("M{}", " 1 2".repeat(1_000_001)); assert_eq!(parse_path(&d).len(), 1_000_000); } + + #[test] + fn a_kurbo_path_becomes_a_custom_path_with_quads_as_cubics() { + let mut bp = kurbo::BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.line_to((30.0, 0.0)); + bp.quad_to((30.0, 30.0), (0.0, 30.0)); + bp.close_path(); + let p = CustomPath::from_bez(&bp, 30.0, 30.0, false); + assert_eq!(p.d, "M 0 0 L 30 0 C 30 20 20 30 0 30 Z"); + assert_eq!(p.fill, FillMode::None); + let mut w = Writer::new(); + write_cust_geom(&mut w, std::slice::from_ref(&p)); + let back = read_cust_geom(&parse(w.finish().as_bytes()).unwrap(), 1.0, 1.0); + assert_eq!(parse_path(&back[0].d).len(), 4); + } } diff --git a/crates/drawingml/src/lib.rs b/crates/drawingml/src/lib.rs index 70e57b2c5..87254bcc1 100644 --- a/crates/drawingml/src/lib.rs +++ b/crates/drawingml/src/lib.rs @@ -163,6 +163,42 @@ impl Xfrm { let r = self.rot.rem_euclid(360.0); if r >= 360.0 { 0.0 } else { r } } + /// The box `inner` occupies under `m` (inner space → parent), as an `a:xfrm`: rotated about + /// its centre, flipped vertically for a negative determinant. `None` when `m` shears (an + /// `a:xfrm` cannot), or is not finite. A shape drawn in `inner`'s space then maps onto the + /// box by the per-axis scales of [`decompose`]`(m)`. + pub fn from_affine(inner: Rect, m: Affine) -> Option { + let dm = decompose(m); + if dm.shear.abs() > 1e-6 || ![dm.sx, dm.sy, dm.rot].iter().all(|v| v.is_finite()) { + return None; + } + let c = m * inner.center(); + let (w, h) = (inner.width() * dm.sx.abs(), inner.height() * dm.sy.abs()); + let x = Xfrm { x: c.x - w / 2.0, y: c.y - h / 2.0, w, h, rot: dm.rot, flip_h: false, flip_v: dm.sy < 0.0 }; + x.is_finite().then_some(x) + } +} + +/// An affine split into rotation, axis scales and shear: `m = translate · rotate(rot) · +/// [sx, shear·sy; 0, sy]`. `rot` is in degrees; `sy` is negative for a mirroring transform. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Decomposed { + pub rot: f64, + pub sx: f64, + pub sy: f64, + pub shear: f64, +} + +/// See [`Decomposed`]. +pub fn decompose(m: Affine) -> Decomposed { + let [a, b, c, d, _, _] = m.as_coeffs(); + let sx = a.hypot(b); + let rot = b.atan2(a); + let (cos, sin) = (rot.cos(), rot.sin()); + let c2 = cos * c + sin * d; + let d2 = -sin * c + cos * d; + let shear = if d2.abs() > 1e-12 { c2 / d2 } else { 0.0 }; + Decomposed { rot: rot.to_degrees(), sx, sy: d2, shear } } /// Maps a group's child coordinate space (`ch_off`, `ch_ext`) onto the group's own box. @@ -218,4 +254,24 @@ mod tests { assert!((0.0..360.0).contains(&n), "rot {rot} -> {n}"); } } + + #[test] + fn an_affine_without_shear_becomes_an_xfrm_that_places_the_box_the_same_way() { + let inner = Rect::new(10.0, 20.0, 110.0, 70.0); + for (rot, sx, sy) in [(0.0, 1.0, 1.0), (30.0, 2.0, 0.5), (-120.0, 1.5, -2.0), (90.0, 1.0, -1.0)] { + let m = Affine::translate((300.0, 200.0)) * Affine::rotate(f64::to_radians(rot)) * Affine::scale_non_uniform(sx, sy); + let x = Xfrm::from_affine(inner, m).unwrap(); + let dm = decompose(m); + assert!((dm.rot - rot).abs() < 1e-9 && (dm.sx - sx).abs() < 1e-9 && (dm.sy - sy).abs() < 1e-9, "{dm:?}"); + assert_eq!(x.flip_v, sy < 0.0); + // The xfrm's local box, scaled from inner space, lands where `m` puts each corner. + let local = |p: Point| Point::new((p.x - inner.x0) * sx.abs(), (p.y - inner.y0) * sy.abs()); + for p in [Point::new(inner.x0, inner.y0), Point::new(inner.x1, inner.y0), Point::new(inner.x1, inner.y1)] { + let (a, b) = (x.affine() * local(p), m * p); + assert!((a - b).hypot() < 1e-9, "rot {rot} sy {sy}: {a:?} vs {b:?}"); + } + } + assert!(Xfrm::from_affine(inner, Affine::skew(0.3, 0.0)).is_none(), "a shear has no xfrm"); + assert!(Xfrm::from_affine(inner, Affine::scale(f64::NAN)).is_none()); + } }