diff --git a/library/src/lib.rs b/library/src/lib.rs index cabafd8cc..c11b818ec 100644 --- a/library/src/lib.rs +++ b/library/src/lib.rs @@ -84,6 +84,7 @@ fn global(math: Module, calc: Module) -> Module { global.define("ellipse", visualize::EllipseElem::func()); global.define("circle", visualize::CircleElem::func()); global.define("polygon", visualize::PolygonElem::func()); + global.define("path", visualize::PathElem::func()); // Meta. global.define("document", meta::DocumentElem::func()); diff --git a/library/src/visualize/mod.rs b/library/src/visualize/mod.rs index 06eec23da..4cf5d04f0 100644 --- a/library/src/visualize/mod.rs +++ b/library/src/visualize/mod.rs @@ -2,10 +2,12 @@ mod image; mod line; +mod path; mod polygon; mod shape; pub use self::image::*; pub use self::line::*; +pub use self::path::*; pub use self::polygon::*; pub use self::shape::*; diff --git a/library/src/visualize/path.rs b/library/src/visualize/path.rs new file mode 100644 index 000000000..44f506e89 --- /dev/null +++ b/library/src/visualize/path.rs @@ -0,0 +1,210 @@ +use self::PathVertex::{AllControlPoints, MirroredControlPoint, Vertex}; +use crate::prelude::*; +use kurbo::{CubicBez, ParamCurveExtrema}; + +/// A path going through a list of points, connected through Bezier curves. +/// +/// ## Example +/// ```example +/// #set page(height: 100pt) +/// #path((10%, 10%), ((20%, 20%), (5%, 5%))) +/// #path((10%, 10%), (10%, 15%)) +/// ``` +/// +/// Display: Path +/// Category: visualize +#[element(Layout)] +pub struct PathElem { + /// Whether to close this path with one last bezier curve. This last curve + /// still takes into account the control points. + /// If you want to close with a straight line, simply add one last point + /// that's the same as the start point. + #[default(false)] + pub closed: bool, + + /// How to fill the polygon. See the + /// [rectangle's documentation]($func/rect.fill) for more details. + /// + /// Currently all paths are filled according to the + /// [non-zero winding rule](https://en.wikipedia.org/wiki/Nonzero-rule). + pub fill: Option, + + /// How to stroke the polygon. See the [lines's + /// documentation]($func/line.stroke) for more details. + #[resolve] + #[fold] + #[default(Some(PartialStroke::default()))] + pub stroke: Option, + + /// The vertices of the path. + /// + /// Each vertex can be defined in 3 ways: + /// + /// - A regular point, like [line]($func/line) + /// - An array of two points, the first being the vertex and the second + /// being the control point. + /// The control point is expressed relative to the vertex and is mirrored + /// to get the second control point. + /// The control point itself refers to the control point that affects the curve coming _into_ this vertex, including for the first point. + /// - An array of three points, the first being the vertex and the next being the control points (control point for curves coming in and out respectively) + #[variadic] + pub vertices: Vec, +} + +impl Layout for PathElem { + fn layout( + &self, + _: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult { + let resolve = |axes: Axes>| { + axes.resolve(styles) + .zip(regions.base()) + .map(|(l, b)| l.relative_to(b)) + .to_point() + }; + + let vertices: Vec = self.vertices(); + let points: Vec = vertices.iter().map(|c| resolve(c.vertex())).collect(); + + let mut size = Size::zero(); + + // Only create a path if there are more than zero points. + let path = if points.len() > 0 { + // Construct a closed path given all points. + let mut path = Path::new(); + path.move_to(points[0]); + + let mut add_cubic = |from_point: Point, + to_point: Point, + from: PathVertex, + to: PathVertex| { + let from_control_point = resolve(from.control_point_from()) + from_point; + let to_control_point = resolve(to.control_point_to()) + to_point; + + path.cubic_to(from_control_point, to_control_point, to_point); + + let p0 = kurbo::Point::new(from_point.x.to_raw(), from_point.y.to_raw()); + let p1 = kurbo::Point::new( + from_control_point.x.to_raw(), + from_control_point.y.to_raw(), + ); + let p2 = kurbo::Point::new( + to_control_point.x.to_raw(), + to_control_point.y.to_raw(), + ); + let p3 = kurbo::Point::new(to_point.x.to_raw(), to_point.y.to_raw()); + let extrema = CubicBez::new(p0, p1, p2, p3).bounding_box(); + size.x.set_max(Abs::raw(extrema.x1)); + size.y.set_max(Abs::raw(extrema.y1)); + }; + + for (vertex_window, point_window) in + vertices.windows(2).zip(points.windows(2)) + { + let from = vertex_window[0]; + let to = vertex_window[1]; + let from_point = point_window[0]; + let to_point = point_window[1]; + + add_cubic(from_point, to_point, from, to); + } + + if self.closed(styles) { + let from = *vertices.last().unwrap(); // We checked that we have at least one element. + let to = vertices[0]; + let from_point = *points.last().unwrap(); + let to_point = points[0]; + + add_cubic(from_point, to_point, from, to); + } + + Some(path) + } else { + None + }; + + let mut frame = Frame::new(size); + if let Some(path) = path { + let fill = self.fill(styles); + let stroke = self.stroke(styles).map(PartialStroke::unwrap_or_default); + let shape = Shape { geometry: Geometry::Path(path), stroke, fill }; + frame.push(Point::zero(), FrameItem::Shape(shape, self.span())); + } + + Ok(Fragment::frame(frame)) + } +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum PathVertex { + Vertex(Axes>), + MirroredControlPoint(Axes>, Axes>), + AllControlPoints(Axes>, Axes>, Axes>), +} + +impl PathVertex { + pub fn vertex(&self) -> Axes> { + match self { + Vertex(x) => *x, + MirroredControlPoint(x, _) => *x, + AllControlPoints(x, _, _) => *x, + } + } + + pub fn control_point_from(&self) -> Axes> { + match self { + Vertex(_) => Axes::new(Rel::zero(), Rel::zero()), + MirroredControlPoint(_, a) => a.map(|x| -x), + AllControlPoints(_, _, b) => *b, + } + } + + pub fn control_point_to(&self) -> Axes> { + match self { + Vertex(_) => Axes::new(Rel::zero(), Rel::zero()), + MirroredControlPoint(_, a) => *a, + AllControlPoints(_, a, _) => *a, + } + } +} + +cast_from_value! { + PathVertex, + array: Array => { + let mut iter = array.into_iter(); + match (iter.next(), iter.next(), iter.next(), iter.next()) { + (Some(a), None, None, None) => { + Vertex(a.cast()?) + }, + (Some(a), Some(b), None, None) => { + if Axes::>::is(&a) { + MirroredControlPoint(a.cast()?, b.cast()?) + } else { + Vertex(Axes::new(a.cast()?, b.cast()?)) + } + }, + (Some(a), Some(b), Some(c), None) => { + AllControlPoints(a.cast()?, b.cast()?, c.cast()?) + }, + _ => Err("path vertex must have 1, 2, or 3 points")?, + } + }, +} + +cast_to_value! { + v: PathVertex => { + match v { + PathVertex::Vertex(x) => { + Value::from(x) + }, + PathVertex::MirroredControlPoint(x, c) => { + Value::Array(array![x, c]) + }, + PathVertex::AllControlPoints(x, c1, c2) => { + Value::Array(array![x, c1, c2]) + }, + } + } +} diff --git a/library/src/visualize/polygon.rs b/library/src/visualize/polygon.rs index 642349fa1..4b4adf7b1 100644 --- a/library/src/visualize/polygon.rs +++ b/library/src/visualize/polygon.rs @@ -58,8 +58,7 @@ impl Layout for PolygonElem { .collect(); let size = points.iter().fold(Point::zero(), |max, c| c.max(max)).to_size(); - let target = regions.expand.select(regions.size, size); - let mut frame = Frame::new(target); + let mut frame = Frame::new(size); // Only create a path if there are more than zero points. if !points.is_empty() { diff --git a/tests/ref/visualize/path.png b/tests/ref/visualize/path.png new file mode 100644 index 000000000..ec537f0a3 Binary files /dev/null and b/tests/ref/visualize/path.png differ diff --git a/tests/typ/visualize/path.typ b/tests/typ/visualize/path.typ new file mode 100644 index 000000000..7a260eb8e --- /dev/null +++ b/tests/typ/visualize/path.typ @@ -0,0 +1,44 @@ +// Test paths. + +--- +#set page(height: 200pt, width: 200pt) +#table( + columns: (1fr, 1fr), + rows: (1fr, 1fr), + align: center + horizon, + path( + fill: red, + stroke: none, + closed: true, + ((0%, 0%), (4%, -4%)), + ((50%, 50%), (4%, -4%)), + ((0%, 50%), (4%, 4%)), + ((50%, 0%), (4%, 4%)), + ), + path( + fill: purple, + (0pt, 0pt), + (30pt, 30pt), + (0pt, 30pt), + (30pt, 0pt), + ), + path( + fill: blue, + closed: true, + ((30%, 0%), (35%, 30%), (-20%, 0%)), + ((30%, 60%), (-20%, 0%), (0%, 0%)), + ((50%, 30%), (60%, -30%), (60%, 0%)), + ), +) + +--- +// Error: 7-9 path vertex must have 1, 2, or 3 points +#path(()) + +--- +// Error: 7-47 path vertex must have 1, 2, or 3 points +#path(((0%, 0%), (0%, 0%), (0%, 0%), (0%, 0%))) + +--- +// Error: 7-31 point array must contain exactly two entries +#path(((0%, 0%), (0%, 0%, 0%)))