//! Live blends, envelope distortions and gradient meshes: their data and pure evaluation. //! //! These are *live* objects. The document stores the source objects plus parameters; the //! renderer and the exporters evaluate them on demand ([`expand_live`], [`expand_deep`]). //! Everything here is pure geometry so the SVG/PDF exporters (which only depend on this crate) //! can export the expanded form. //! //! - **Blend**: key objects are matched anchor-by-anchor (subpaths resampled to equal anchor //! counts), paints/strokes/opacity interpolated; optional spine (keys spread along it by arc //! length, optionally rotated to its tangent). //! - **Envelope**: all descendant geometry of the content is pushed through a point map from the //! content's bounding box to a warp style, a Catmull-Rom point mesh or a top object (Coons //! patch built from the top path's outline split at its corners). //! - **Gradient mesh**: a grid of points with colours and tangent handles; each patch is a Coons //! patch coloured by bilinear interpolation of its four corners. use std::f64::consts::PI; use std::sync::Arc; use serde::{Deserialize, Serialize}; use vectorcraft_color::{Color, Paint}; use vectorcraft_geom::{Affine, Anchor, BezPath, CubicBez, ParamCurve, PathData, Point, Rect, Shape, SubPath, Vec2}; use crate::appearance::{Appearance, AppearanceItem, FillLayer}; use crate::node::{ImageObject, Node, NodeId, NodeKind}; fn yes() -> bool { true } fn one() -> f32 { 1.0 } fn fifty() -> f64 { 50.0 } // ===================================================================================== // Blends (evaluation in `crate::blend`) // ===================================================================================== pub use crate::blend::{ Spine, blend_expand, blend_spine, blend_step_count, lerp_appearance, lerp_color, lerp_node, lerp_paint, lerp_path, pin_spine, steps_knockout_shows, }; mod blend_spec; mod envelope; mod expand; mod mesh; mod warp; pub use blend_spec::{BlendDefaults, BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec}; #[cfg(test)] use blend_spec::{blend_color_shift, blend_ramp}; pub use envelope::{ EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, envelope_bounds, envelope_expand, envelope_frame, envelope_grid, envelope_mapper, envelope_of, envelope_overlay, smooth_handles, }; pub(crate) use expand::max_outset; pub use expand::{ Hooks, MAX_GENERATED, Outliner, PatternTiles, affine_near, expand_deep, expand_deep_hooks, expand_live, expand_live_hooks, expand_live_with, expanded_group, expanded_group_hooks, is_live, mesh_quad_nodes, subtree_has_live, }; #[cfg(test)] use mesh::grid_eval; pub use mesh::{Coons, GradientMesh, MeshAppearance, MeshPoint, MeshQuad, default_fidelity, grid_points, nodes_bounds}; pub use warp::{WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, warp_point}; #[cfg(test)] mod tests { use super::*; /// A bad Ramp or Shift in a file clamps (non-finite values take the default) instead of /// failing the whole document. #[test] fn blend_easing_numbers_clamp_when_read() { use serde::de::value::{Error, F64Deserializer}; let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"kind":"out","ramp":7.5},"colorShift":-900}"#).unwrap(); assert_eq!((spec.ease.ramp, spec.color_shift), (1.0, -100.0)); let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"ramp":-3},"colorShift":250}"#).unwrap(); assert_eq!((spec.ease.ramp, spec.color_shift), (0.0, 100.0)); let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"ramp":null},"colorShift":"NaN"}"#).unwrap(); assert_eq!((spec.ease.ramp, spec.color_shift), (0.0, 0.0)); for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { assert_eq!(blend_ramp(F64Deserializer::::new(bad)).unwrap(), 0.0); assert_eq!(blend_color_shift(F64Deserializer::::new(bad)).unwrap(), 0.0); } } /// #3578: a non-linear map within an anchor budget splits its segments into longer pieces, /// down to one a segment, and matches the plain map where the budget leaves room. #[test] fn map_nonlinear_within_keeps_to_its_anchor_budget() { let square = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)); let f = |p: Point| Point::new(p.x + (p.y / 10.0).sin(), p.y); let full = map_nonlinear(&square, 1.0, f); assert_eq!(full.anchor_count(), 4 * 64); assert_eq!(map_nonlinear_within(&square, 1.0, 4 * 64, f), full); // Every side takes the same pieces: of 400 / (max - 4) pt, so 12 a side for 50 anchors. for (max, want) in [(0, 4), (4, 4), (5, 4), (50, 4 * 12), (255, 4 * 63)] { assert_eq!(map_nonlinear_within(&square, 1.0, max, f).anchor_count(), want, "{max}"); } // Lengths that are not numbers stay one piece; with no room every segment is one piece. assert_eq!(fit_piece(&[f64::NAN, 10.0], 1.0, 64, 5), 2.0); assert_eq!(fit_piece(&[f64::INFINITY, 10.0], 1.0, 64, 0), f64::INFINITY); assert_eq!(pieces(f64::INFINITY, f64::INFINITY, 64), 1); assert_eq!(pieces(10.0, 1.0, 0), 1); } /// #1578: Arc bends in the box's real proportions. In half-width units (y scaled by the /// aspect), the top and bottom edges are concentric circles about the bend's centre, at their /// real distance from the centre line; an inner edge that would pass the centre stops there. #[test] fn arc_warp_is_round_in_real_proportions() { for (aspect, b) in [(0.25, 0.1), (0.25, 0.5), (0.25, 1.0), (1.0, 0.5), (2.0, 0.3)] { let r0 = 2.0 / (b * PI); for (y, r) in [(-1.0, r0 + aspect), (1.0, (r0 - aspect).max(0.0))] { for i in 0..=10 { let x = -1.0 + 0.2 * f64::from(i); let (nx, ny) = warp_point(WarpStyle::Arc, b, 0.0, 0.0, aspect, x, y); let d = nx.hypot(ny * aspect - r0); assert!((d - r).abs() < 1e-9, "aspect {aspect}, bend {b}, y {y}, x {x}: {d} vs {r}"); } } } // At 100 % on a square the inner edge meets at the centre instead of folding over. let (nx, _) = warp_point(WarpStyle::Arc, 1.0, 0.0, 0.0, 1.0, 0.8, 1.0); assert!(nx.abs() < 1e-12); // A square bent less than that is what it always was. let (nx, ny) = warp_point(WarpStyle::Arc, 0.5, 0.0, 0.0, 1.0, 0.5, 0.25); let (r0, a) = (4.0 / PI, 0.5 / (4.0 / PI)); assert!((nx - (r0 - 0.25) * a.sin()).abs() < 1e-12 && (ny - (r0 - (r0 - 0.25) * a.cos())).abs() < 1e-12); } use vectorcraft_geom::shapes; fn rect_node(x: f64, y: f64, w: f64, h: f64, c: Color) -> Arc { Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(x, y, x + w, y + h)), Appearance::basic(Paint::solid(c), Paint::None, 0.0))) } #[test] fn lerp_path_equalises_anchor_counts() { let a = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)); let b = shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)); let t = lerp_path(&a, &shapes::polygon(Point::new(5.0, 5.0), 5.0, 7, 0.0), 0.5); assert_eq!(t.subpaths[0].anchors.len(), 7); let m = lerp_path(&a, &b, 0.0); assert_eq!(m.subpaths[0].anchors.len(), 4); } #[test] fn blend_steps_and_colours() { let keys = vec![rect_node(0.0, 0.0, 10.0, 10.0, Color::BLACK), rect_node(100.0, 0.0, 10.0, 10.0, Color::WHITE)]; let out = blend_expand(&keys, &BlendSpec { spacing: BlendSpacing::Steps(3), ..Default::default() }); assert_eq!(out.len(), 5); let mid = out[2].appearance.fill_paint().color().unwrap().to_rgb()[0]; assert!((mid - 0.5).abs() < 1e-3); } #[test] fn coons_rect_is_identity() { let c = Coons::from_path(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0))).unwrap(); let p = c.eval(0.25, 0.5); assert!((p.x - 25.0).abs() < 1e-6 && (p.y - 25.0).abs() < 1e-6, "{p:?}"); } #[test] fn maps_go_on_past_the_content_box() { // A stroke outline reaches past the box: a flat mesh and a rectangle stay the identity there. let r = Rect::new(0.0, 0.0, 100.0, 100.0); let p = grid_eval(2, 2, &grid_points(r, 2, 2), -0.05, 1.1); assert!((p.x + 5.0).abs() < 1e-6 && (p.y - 110.0).abs() < 1e-6, "{p:?}"); let c = Coons::from_path(&shapes::rectangle(r)).unwrap().eval(1.05, -0.1); assert!((c.x - 105.0).abs() < 1e-6 && (c.y + 10.0).abs() < 1e-6, "{c:?}"); } #[test] fn grid_identity() { let r = Rect::new(0.0, 0.0, 100.0, 100.0); let pts = grid_points(r, 3, 4); let p = grid_eval(3, 4, &pts, 0.37, 0.81); assert!((p.x - 37.0).abs() < 1e-6 && (p.y - 81.0).abs() < 1e-6, "{p:?}"); } /// A file's rows and columns can be anything: a point count too large to compute is no match /// for the points (u32::MAX × u32::MAX wrapped to 0 and passed for a grid without points). #[test] fn a_grid_too_large_to_count_is_malformed() { let m = GradientMesh { rows: u32::MAX, cols: u32::MAX, points: vec![] }; assert!(!m.is_valid()); assert_eq!((m.lines(), m.outline(), m.quads(8).len()), (PathData::default(), PathData::default(), 0)); assert!(envelope_grid(u32::MAX, u32::MAX, &[], &[]).is_none()); // A mesh envelope without handles maps through its points: malformed, it leaves art as it is. let kind = EnvelopeKind::Mesh { rows: u32::MAX, cols: u32::MAX, points: vec![], handles: vec![] }; let p = Point::new(25.0, 50.0); assert_eq!(envelope_mapper(&kind, Rect::new(0.0, 0.0, 100.0, 100.0))(p), p); } #[test] fn mesh_insert_and_remove_lines() { let mut m = GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); let i = m.add_lines_at(Point::new(30.0, 60.0)).unwrap(); assert_eq!((m.rows, m.cols), (2, 2)); assert!(m.points[i].p.distance(Point::new(30.0, 60.0)) < 0.5, "{:?}", m.points[i].p); assert!(m.remove_point_lines(i)); assert_eq!((m.rows, m.cols), (1, 1)); } /// Corners all in CMYK mix in CMYK (the inks halfway), not in RGB. #[test] fn a_cmyk_mesh_mixes_its_inks() { let cyan = Color::Cmyk { c: 1.0, m: 0.0, y: 0.0, k: 0.0 }; let mut m = GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), 1, 1, cyan, MeshAppearance::Flat, 0.0).unwrap(); m.points[0].color = Color::Cmyk { c: 0.0, m: 0.0, y: 0.0, k: 1.0 }; let (c, op) = m.color_at(0, 0, 0.0, 0.0); assert_eq!((c, op), (Color::Cmyk { c: 0.0, m: 0.0, y: 0.0, k: 1.0 }, 1.0)); let Color::Cmyk { c, k, .. } = m.color_at(0, 0, 0.5, 0.5).0 else { panic!("mixed out of CMYK") }; assert!((c - 0.75).abs() < 1e-6 && (k - 0.25).abs() < 1e-6, "{c} {k}"); // A corner in another space mixes them all in RGB. m.points[1].color = Color::rgb(1.0, 0.0, 0.0); assert!(matches!(m.color_at(0, 0, 0.5, 0.5).0, Color::Rgb { .. })); } /// Alt-clicking a point of a mesh from a file with fewer points than its rows and columns need /// deletes nothing (it indexed past the points, a panic). #[test] fn mesh_lines_of_a_malformed_mesh_stay() { let point = |i: u32| MeshPoint { p: Point::new(f64::from(i), 0.0), color: Color::BLACK, opacity: 1.0, handles: [Vec2::ZERO; 4] }; // An interior row's point and an interior column's point, each with 3 of the 6 points. for (rows, cols, index) in [(2, 1, 2), (1, 2, 1)] { let mut m = GradientMesh { rows, cols, points: (0..3).map(point).collect() }; let before = m.clone(); assert!(!m.remove_point_lines(index), "{rows}×{cols}, point {index}"); assert_eq!(m, before); } } }