use crate::artist::Path;
use crate::core::Affine2D;
use crate::core::path::PathCode;
const HALF: f64 = 0.5;
const STAR_INNER: f64 = 0.381_966;
#[derive(Debug, Clone, PartialEq)]
pub struct MarkerStyle {
path: Path,
filled: bool,
}
impl MarkerStyle {
#[must_use]
pub fn from_char(c: char) -> Option<MarkerStyle> {
let style = match c {
'o' => MarkerStyle::filled(unit_circle()),
'.' => MarkerStyle::filled(point_circle()),
',' => MarkerStyle::filled(pixel()),
's' => MarkerStyle::filled(square()),
'^' => MarkerStyle::unfilled(triangle(Triangle::Up)),
'v' => MarkerStyle::unfilled(triangle(Triangle::Down)),
'<' => MarkerStyle::unfilled(triangle(Triangle::Left)),
'>' => MarkerStyle::unfilled(triangle(Triangle::Right)),
'D' => MarkerStyle::filled(diamond(1.0)),
'd' => MarkerStyle::unfilled(diamond(0.6)),
'p' => MarkerStyle::unfilled(scaled_polygon(5)),
'h' | 'H' => MarkerStyle::unfilled(scaled_polygon(6)),
'*' => MarkerStyle::filled(star()),
'+' => MarkerStyle::unfilled(plus()),
'x' => MarkerStyle::unfilled(cross()),
_ => return None,
};
Some(style)
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
#[must_use]
pub fn is_filled(&self) -> bool {
self.filled
}
#[must_use]
pub fn scaled(&self, size: f64) -> Path {
self.path.transformed(&Affine2D::from_scale(size, size))
}
fn filled(path: Path) -> MarkerStyle {
MarkerStyle { path, filled: true }
}
fn unfilled(path: Path) -> MarkerStyle {
MarkerStyle {
path,
filled: false,
}
}
}
#[derive(Debug, Clone, Copy)]
enum Triangle {
Up,
Down,
Left,
Right,
}
fn unit_circle() -> Path {
Path::unit_circle().transformed(&Affine2D::from_scale(HALF, HALF))
}
fn point_circle() -> Path {
Path::unit_circle().transformed(&Affine2D::from_scale(HALF * 0.2, HALF * 0.2))
}
fn pixel() -> Path {
let r = HALF * 0.1;
Path::new(
vec![[-r, -r], [r, -r], [r, r], [-r, r], [-r, -r]],
Some(vec![
PathCode::MoveTo,
PathCode::LineTo,
PathCode::LineTo,
PathCode::LineTo,
PathCode::ClosePoly,
]),
)
}
fn square() -> Path {
Path::new(
vec![
[-HALF, -HALF],
[HALF, -HALF],
[HALF, HALF],
[-HALF, HALF],
[-HALF, -HALF],
],
Some(vec![
PathCode::MoveTo,
PathCode::LineTo,
PathCode::LineTo,
PathCode::LineTo,
PathCode::ClosePoly,
]),
)
}
fn scaled_polygon(num_sides: usize) -> Path {
Path::unit_regular_polygon(num_sides).transformed(&Affine2D::from_scale(HALF, HALF))
}
fn star() -> Path {
Path::unit_regular_star(5, STAR_INNER).transformed(&Affine2D::from_scale(HALF, HALF))
}
fn diamond(aspect: f64) -> Path {
let half_x = HALF * aspect;
Path::new(
vec![
[0.0, -HALF],
[half_x, 0.0],
[0.0, HALF],
[-half_x, 0.0],
[0.0, -HALF],
],
Some(vec![
PathCode::MoveTo,
PathCode::LineTo,
PathCode::LineTo,
PathCode::LineTo,
PathCode::ClosePoly,
]),
)
}
fn triangle(orientation: Triangle) -> Path {
let deg = match orientation {
Triangle::Up => 0.0,
Triangle::Down => 180.0,
Triangle::Left => 90.0,
Triangle::Right => -90.0,
};
Path::unit_regular_polygon(3).transformed(&Affine2D::from_rotation_deg(deg).scale(HALF, HALF))
}
fn plus() -> Path {
Path::new(
vec![[-HALF, 0.0], [HALF, 0.0], [0.0, -HALF], [0.0, HALF]],
Some(vec![
PathCode::MoveTo,
PathCode::LineTo,
PathCode::MoveTo,
PathCode::LineTo,
]),
)
}
fn cross() -> Path {
Path::new(
vec![[-HALF, -HALF], [HALF, HALF], [-HALF, HALF], [HALF, -HALF]],
Some(vec![
PathCode::MoveTo,
PathCode::LineTo,
PathCode::MoveTo,
PathCode::LineTo,
]),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn approx(a: f64, b: f64, tol: f64) {
assert!((a - b).abs() < tol, "expected {b}, got {a}");
}
fn count_movetos(path: &Path) -> usize {
path.codes()
.map(|codes| codes.iter().filter(|&&c| c == PathCode::MoveTo).count())
.unwrap_or(0)
}
#[test]
fn circle_is_filled_and_unit_centered() {
let m = MarkerStyle::from_char('o').unwrap();
assert!(m.is_filled());
let e = m.path().get_extents();
approx((e.xmin() + e.xmax()) / 2.0, 0.0, 1e-9);
approx((e.ymin() + e.ymax()) / 2.0, 0.0, 1e-9);
approx(e.xmax() - e.xmin(), 1.0, 1e-9);
approx(e.ymax() - e.ymin(), 1.0, 1e-9);
}
#[test]
fn square_has_four_distinct_corners() {
let m = MarkerStyle::from_char('s').unwrap();
let mut corners: Vec<[f64; 2]> = m.path().vertices().to_vec();
corners.dedup();
if corners.first() == corners.last() {
corners.pop();
}
assert_eq!(corners.len(), 4, "square should have 4 distinct corners");
}
#[test]
fn plus_is_unfilled_with_two_subpaths() {
let m = MarkerStyle::from_char('+').unwrap();
assert!(!m.is_filled());
assert!(
count_movetos(m.path()) >= 2,
"plus should contain two subpaths"
);
}
#[test]
fn cross_is_unfilled_with_two_subpaths() {
let m = MarkerStyle::from_char('x').unwrap();
assert!(!m.is_filled());
assert!(count_movetos(m.path()) >= 2);
}
#[test]
fn triangle_up_apex_is_above_origin() {
let m = MarkerStyle::from_char('^').unwrap();
let e = m.path().get_extents();
assert!(e.ymax() > 0.0, "apex should be above origin");
approx(e.ymax(), -e.ymin() * 2.0, 1e-6);
}
#[test]
fn unknown_char_returns_none() {
assert!(MarkerStyle::from_char('?').is_none());
}
#[test]
fn scaled_doubles_extents() {
let m = MarkerStyle::from_char('o').unwrap();
let e1 = m.scaled(1.0).get_extents();
let e2 = m.scaled(2.0).get_extents();
approx(e2.xmax() - e2.xmin(), 2.0 * (e1.xmax() - e1.xmin()), 1e-9);
approx(e2.ymax() - e2.ymin(), 2.0 * (e1.ymax() - e1.ymin()), 1e-9);
}
}