Files
deckcraft/crates/geom/src/lib.rs
T
alexanderalberandBrandon Thomas 788831834b Morph: a changed geometry morphs its outline (a circle turns into a star) (#27)
* Morph: a changed geometry morphs its outline (a circle turns into a star)

Paired shapes that differ only in geometry (another preset, other adjust
values, a custom path) and fill colour no longer cross-fade: one shape moves
and its outline turns from the old geometry into the new one.

- geom::morph aligns two outlines the way manim does: the same number of
  contours, the same number of cubic curves per contour pair, then every
  point moves in a straight line. Closed contours are turned and reversed so
  the points travel least (a triangle does not twist into a circle), extra
  points go to the longest curves, and a sub-path without partner grows out
  of (or shrinks into) a point, so a cube's faces appear and a donut's hole
  closes. Fill mode, outline and fill rule switch at the midpoint.
- anim::morph_frame lists such shapes in MorphFrame::paths; the renderer
  draws them with the morphed geometry (PathMorph, a new render_blend
  argument next to TextMorph).

Ported from the path morph in alexplain (MIT, same author).

generated with Claude Code

* Morph: differing looks of an outline cross-fade instead of switching

When a sub-path pair differs in fill mode, outline or fill rule, the two
looks no longer switch at the midpoint. A fill or outline only one side has
fades out (or in), so a filled shape turning into a line loses its fill
gradually. When both fill, the even-odd side lies opaque underneath and the
other fades over it, so a donut's hole fills smoothly while it shrinks.

generated with Claude Code

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 06:19:32 -04:00

273 lines
9.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 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<f64> {
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);
}
}