diff --git a/crates/geom/src/preset.rs b/crates/geom/src/preset.rs index 75e729d4..b6970e60 100644 --- a/crates/geom/src/preset.rs +++ b/crates/geom/src/preset.rs @@ -2227,425 +2227,4 @@ fn action_button(name: &str, w: f64, h: f64) -> Geometry { } #[cfg(test)] -mod tests { - use super::*; - use kurbo::Shape; - - #[test] - fn every_catalog_preset_builds_within_bounds() { - for p in CATALOG { - for (w, h) in [(200.0, 100.0), (100.0, 200.0), (50.0, 50.0), (0.0, 0.0), (1.0, 300.0)] { - let g = build(p.name, w, h, &[]).unwrap_or_else(|| panic!("{} did not build", p.name)); - assert!(!g.paths.is_empty(), "{} has no paths", p.name); - for s in &g.paths { - let bb = s.path.bounding_box(); - if w > 0.0 && h > 0.0 && !p.name.contains("Connector") && !p.name.contains("allout") { - let tol = w.max(h) * 0.35 + 1.0; - assert!(bb.x0 >= -tol && bb.y0 >= -tol && bb.x1 <= w + tol && bb.y1 <= h + tol, "{} {w}x{h} out of bounds: {bb:?}", p.name); - } - assert!(bb.x0.is_finite() && bb.y1.is_finite(), "{} produced non-finite geometry", p.name); - } - assert!(g.text_rect.x0.is_finite() && g.text_rect.y1.is_finite(), "{}", p.name); - } - } - } - - #[test] - fn hostile_adjust_values_never_panic() { - for p in CATALOG { - for v in [f64::NAN, f64::INFINITY, -1e12, 1e12, -1.0, 0.0] { - let adj = vec![v; 6]; - let g = build(p.name, 120.0, 80.0, &adj); - assert!(g.is_some(), "{}", p.name); - } - } - assert!(build("rect", f64::NAN, 10.0, &[]).is_none()); - assert!(build("noSuchShape", 10.0, 10.0, &[]).is_none()); - } - - #[test] - fn catalog_names_are_unique() { - let mut names: Vec<&str> = CATALOG.iter().map(|p| p.name).collect(); - names.sort_unstable(); - let n = names.len(); - names.dedup(); - assert_eq!(n, names.len()); - assert!(n >= 140, "catalog has {n} presets"); - } - - #[test] - fn round_rect_handle_maps_back() { - let g = build("roundRect", 200.0, 100.0, &[16667.0]).unwrap(); - let h = g.handles.first().unwrap(); - assert!((h.value_at(h.pos) - 16667.0).abs() <= 1.0); - assert_eq!(h.value_at(Point::new(-50.0, 0.0)), 0.0); - assert_eq!(h.value_at(Point::new(500.0, 0.0)), 50000.0); - } - - #[test] - fn partial_circle_angle_handles_map_back() { - // Start 0° sits at the right of the ellipse, end 270° at its top. - for name in ["pie", "chord", "arc"] { - let g = build(name, 200.0, 100.0, &[0.0, 16200000.0]).unwrap(); - assert_eq!(g.handles.iter().map(|h| h.index).collect::>(), vec![0, 1], "{name}"); - let (s, e) = (&g.handles[0], &g.handles[1]); - assert!((s.pos - Point::new(200.0, 50.0)).hypot() < 1e-6, "{name} {:?}", s.pos); - assert!((e.pos - Point::new(100.0, 0.0)).hypot() < 1e-6, "{name} {:?}", e.pos); - assert_eq!(s.value_at(s.pos), 0.0, "{name}"); - assert_eq!(e.value_at(e.pos), 16200000.0, "{name}"); - // Dragging to the bottom of the ellipse gives 90°. - assert_eq!(s.value_at(Point::new(100.0, 100.0)), 5400000.0, "{name}"); - // Off-axis angles on a non-square ellipse: the visual 45° ray meets the 200×100 - // ellipse at offset (√2000, √2000) from the centre. - let k = 2000f64.sqrt(); - let g = build(name, 200.0, 100.0, &[2700000.0, 8100000.0]).unwrap(); - let (s, e) = (&g.handles[0], &g.handles[1]); - assert!((s.pos - Point::new(100.0 + k, 50.0 + k)).hypot() < 1e-6, "{name} {:?}", s.pos); - assert!((e.pos - Point::new(100.0 - k, 50.0 + k)).hypot() < 1e-6, "{name} {:?}", e.pos); - for (deg, h) in [(45.0, s), (135.0, e)] { - let on = ((h.pos.x - 100.0) / 100.0).powi(2) + ((h.pos.y - 50.0) / 50.0).powi(2); - assert!((on - 1.0).abs() < 1e-9, "{name} {deg}° off ellipse: {:?}", h.pos); - assert!((h.value_at(h.pos) - deg * 60000.0).abs() <= 1.0, "{name} {deg}°: {}", h.value_at(h.pos)); - } - } - for deg in [45.0, 135.0, 200.0, 315.0] { - let g = build("blockArc", 200.0, 100.0, &[deg * 60000.0, 0.0, 25000.0]).unwrap(); - let start = g.handles.iter().find(|h| h.index == 0).unwrap(); - // Thickness 25 → the handle runs on the 87.5×37.5 mid-ring ellipse. - let on = ((start.pos.x - 100.0) / 87.5).powi(2) + ((start.pos.y - 50.0) / 37.5).powi(2); - assert!((on - 1.0).abs() < 1e-9, "blockArc {deg}° off ring: {:?}", start.pos); - let d = start.pos - Point::new(100.0, 50.0); - assert!((d.y.atan2(d.x).to_degrees().rem_euclid(360.0) - deg).abs() < 1e-6, "blockArc {deg}°: {:?}", start.pos); - assert!((start.value_at(start.pos) - deg * 60000.0).abs() <= 1.0, "blockArc {deg}°"); - } - let g = build("blockArc", 200.0, 100.0, &[10800000.0, 0.0, 25000.0]).unwrap(); - let start = g.handles.iter().find(|h| h.index == 0).unwrap(); - assert_eq!(start.value_at(start.pos), 10800000.0); - let thick = g.handles.iter().find(|h| h.index == 2).unwrap(); - assert!((thick.value_at(thick.pos) - 25000.0).abs() <= 1.0); - } - - #[test] - fn presets_with_adjust_values_expose_handles() { - // Presets whose adjust values have no handle of their own yet. - const NO_HANDLES: &[&str] = &[ - "curvedConnector3", - "snip2SameRect", - "snip2DiagRect", - "snipRoundRect", - "round1Rect", - "round2SameRect", - "round2DiagRect", - "teardrop", - "halfFrame", - "corner", - "diagStripe", - "foldedCorner", - "smileyFace", - "moon", - "gear6", - "gear9", - "bracketPair", - "bracePair", - "leftBracket", - "rightBracket", - "leftBrace", - "rightBrace", - "leftRightArrow", - "upDownArrow", - "quadArrow", - "leftRightUpArrow", - "bentArrow", - "uturnArrow", - "curvedRightArrow", - "curvedLeftArrow", - "curvedUpArrow", - "curvedDownArrow", - "leftUpArrow", - "bentUpArrow", - "circularArrow", - "leftCircularArrow", - "leftRightCircularArrow", - "swooshArrow", - "rightArrowCallout", - "downArrowCallout", - "leftArrowCallout", - "upArrowCallout", - "leftRightArrowCallout", - "upDownArrowCallout", - "quadArrowCallout", - "mathPlus", - "mathMinus", - "mathMultiply", - "mathDivide", - "mathEqual", - "mathNotEqual", - "ribbon2", - "ribbon", - "ellipseRibbon2", - "ellipseRibbon", - "leftRightRibbon", - "verticalScroll", - "horizontalScroll", - "wave", - "doubleWave", - "borderCallout1", - "borderCallout2", - "borderCallout3", - "accentCallout1", - "accentCallout2", - "accentCallout3", - "callout1", - "callout2", - "callout3", - "accentBorderCallout1", - "accentBorderCallout2", - "accentBorderCallout3", - ]; - for p in CATALOG { - if p.defaults.is_empty() || NO_HANDLES.contains(&p.name) { - continue; - } - let g = build(p.name, 200.0, 100.0, &[]).unwrap(); - assert!(!g.handles.is_empty(), "{} has adjust values but no handles", p.name); - } - } - - #[test] - fn rect_area_and_ellipse_area() { - let g = build("rect", 100.0, 50.0, &[]).unwrap(); - assert!((g.paths[0].path.area().abs() - 5000.0).abs() < 1e-6); - let e = build("ellipse", 100.0, 50.0, &[]).unwrap(); - let expect = std::f64::consts::PI * 50.0 * 25.0; - assert!((e.paths[0].path.area().abs() - expect).abs() / expect < 0.01); - } - - #[test] - fn lines_are_open() { - assert!(build("line", 10.0, 10.0, &[]).unwrap().is_open()); - assert!(!build("rect", 10.0, 10.0, &[]).unwrap().is_open()); - } - - fn on_path(g: &Geometry, p: Point, tol: f64) -> bool { - use kurbo::ParamCurveNearest; - g.paths.iter().any(|sp| sp.path.segments().any(|seg| seg.nearest(p, 1e-6).distance_sq <= tol * tol)) - } - - #[test] - fn fixed_shapes_follow_the_standards_coordinates() { - // Points of ECMA-376's definitions (in 21600ths), checked on a 216 × 108 box. - let (w, h) = (216.0, 108.0); - let at = |x: f64, y: f64| Point::new(w * x / 21600.0, h * y / 21600.0); - let g = build("lightningBolt", w, h, &[]).unwrap(); - for (x, y) in [(8472.0, 0.0), (16577.0, 12007.0), (21600.0, 21600.0), (0.0, 3890.0)] { - assert!(on_path(&g, at(x, y), 1e-6), "lightningBolt ({x}, {y})"); - } - let g = build("irregularSeal1", w, h, &[]).unwrap(); - assert!(on_path(&g, at(14522.0, 0.0), 1e-6) && on_path(&g, at(8485.0, 21600.0), 1e-6)); - let g = build("irregularSeal2", w, h, &[]).unwrap(); - assert!(on_path(&g, at(14790.0, 0.0), 1e-6) && on_path(&g, at(4917.0, 21600.0), 1e-6)); - // The heart's point is the middle of the bottom edge, its notch a quarter down. - let g = build("heart", w, h, &[]).unwrap(); - assert!(on_path(&g, Point::new(w / 2.0, h), 1e-6) && on_path(&g, Point::new(w / 2.0, h / 4.0), 1e-6)); - let g = build("flowChartDocument", w, h, &[]).unwrap(); - assert!(on_path(&g, at(21600.0, 17322.0), 1e-6) && on_path(&g, at(0.0, 20172.0), 1e-6)); - // Punched tape: the top edge dips to a fifth of the height a quarter of the way along. - let g = build("flowChartPunchedTape", w, h, &[]).unwrap(); - assert!(on_path(&g, Point::new(w / 4.0, h / 5.0), 1e-3) && on_path(&g, Point::new(w * 0.75, 0.0), 1e-3)); - let g = build("flowChartMultidocument", w, h, &[]).unwrap(); - assert!(on_path(&g, at(2972.0, 0.0), 1e-6) && on_path(&g, at(18595.0, 18022.0), 1e-6)); - } - - #[test] - fn notched_arrow_notch_parallels_the_head() { - // presetShapeDefinitions: the notch's point is at x1 = dy1 · dx2 / hd2, so its edges run - // parallel to the head's (25 on a 200 × 100 arrow at the default adjusts). - for (w, h, adj, x1) in [(200.0, 100.0, [50000.0, 50000.0], 25.0), (300.0, 120.0, [80000.0, 30000.0], 28.8)] { - let g = build("notchedRightArrow", w, h, &adj).unwrap(); - let notch = g.paths[0].path.elements().iter().find_map(|e| match e { - kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) if (p.y - h / 2.0).abs() < 1e-9 && p.x < w / 2.0 => Some(*p), - _ => None, - }); - assert!(notch.is_some_and(|p| (p.x - x1).abs() < 1e-6), "{w}x{h} {adj:?}: notch at {notch:?}, want x = {x1}"); - } - } - - #[test] - fn equation_shapes_match_powerpoints_outlines() { - // Corners read off PowerPoint's own outlines of the presets at their default adjusts. - let g = build("mathMultiply", 216.0, 216.0, &[]).unwrap(); - for (x, y) in [(0.32333, 0.15702), (0.5, 0.33369), (0.84298, 0.32333), (0.66631, 0.5), (0.33369, 0.5)] { - assert!(on_path(&g, Point::new(216.0 * x, 216.0 * y), 0.01), "multiply ({x}, {y})"); - } - let g = build("mathEqual", 300.0, 120.0, &[]).unwrap(); - for (x, y) in [(0.13255, 0.5588), (0.86745, 0.794), (0.13255, 0.206), (0.86745, 0.4412)] { - assert!(on_path(&g, Point::new(300.0 * x, 120.0 * y), 0.01), "equal ({x}, {y})"); - } - let g = build("mathPlus", 216.0, 216.0, &[]).unwrap(); - assert!(on_path(&g, Point::new(216.0 * 0.3824, 216.0 * 0.13255), 0.01) && on_path(&g, Point::new(216.0 * 0.86745, 216.0 * 0.6176), 0.01)); - // A tall "not equal": its slash, leaning at the default 110° from top to bottom, reaches - // past the box's sides exactly as far as PowerPoint's (-22.242 to 122.242). - let g = build("mathNotEqual", 100.0, 260.0, &[]).unwrap(); - use kurbo::Shape as _; - let bb = g.paths[0].path.bounding_box(); - assert!((bb.x0 + 22.242).abs() < 0.01 && (bb.x1 - 122.242).abs() < 0.01 && bb.y0.abs() < 1e-6 && (bb.y1 - 260.0).abs() < 1e-6, "{bb:?}"); - } - - #[test] - fn sun_and_waves_match_powerpoints_outlines() { - // Points read off PowerPoint's outlines of the presets (216 pt square, default adjusts). - let k = 216.0; - let g = build("sun", k, k, &[]).unwrap(); - for (x, y) in [(0.85352, 0.14644), (0.76161, 0.33987), (0.66013, 0.23839), (0.42825, 0.79823), (0.20177, 0.57175), (0.75, 0.5), (0.5, 0.25)] { - assert!(on_path(&g, Point::new(k * x, k * y), 0.02), "sun ({x}, {y})"); - } - let g = build("wave", k, k, &[]).unwrap(); - for (x, y) in [(0.21875, 0.00484), (1.0, 0.125), (1.0, 0.875), (0.0, 0.875)] { - assert!(on_path(&g, Point::new(k * x, k * y), 0.05), "wave ({x}, {y})"); - } - // Shifted: a wave of 300 × 120 with adj (20000, 8000) starts 0.16 of the width in. - let g = build("wave", 300.0, 120.0, &[20000.0, 8000.0]).unwrap(); - for (x, y) in [(0.18375, 0.00774), (0.84, 0.2), (1.0, 0.8), (0.16, 0.8)] { - assert!(on_path(&g, Point::new(300.0 * x, 120.0 * y), 0.05), "shifted wave ({x}, {y})"); - } - } - - #[test] - fn star_text_rectangles_follow_the_standard() { - // star5 at its default 19098 in a 144 pt square: the inner pentagon, about 0.38 of the - // width (PowerPoint wraps "Shape 3" at 18 pt there), from y ≈ 55 to ≈ 110. - let g = build("star5", 144.0, 144.0, &[]).unwrap(); - let t = g.text_rect; - assert!((t.width() - 55.0).abs() < 1.0, "{t:?}"); - assert!((t.y0 - 55.0).abs() < 1.0 && (t.y1 - 110.0).abs() < 1.0, "{t:?}"); - // star4 at 12500: the square through its inner points' 45° corners. - let g = build("star4", 144.0, 144.0, &[]).unwrap(); - assert!((g.text_rect.width() - 72.0 * 0.25 * 2f64.sqrt()).abs() < 0.5, "{:?}", g.text_rect); - } - - #[test] - fn stars_are_stretched_like_the_standards() { - // star5 in a 216 pt square: top point at the top middle, side points at 18 degrees on an - // ellipse 5.146% wider and 10.557% taller, centred 10.557% below the middle; its bottom - // points reach the box's bottom edge. - let k = 216.0; - let g = build("star5", k, k, &[]).unwrap(); - let (rx, ry, cy) = (k / 2.0 * 1.05146, k / 2.0 * 1.10557, k / 2.0 * 1.10557); - let a = 18f64.to_radians(); - assert!(on_path(&g, Point::new(k / 2.0, 0.0), 1e-6)); - assert!(on_path(&g, Point::new(k / 2.0 + rx * a.cos(), cy - ry * a.sin()), 1e-6)); - use kurbo::Shape as _; - let bb = g.paths[0].path.bounding_box(); - assert!((bb.x0).abs() < 0.5 && (bb.x1 - k).abs() < 0.5 && (bb.y1 - k).abs() < 0.5, "{bb:?}"); - // star6 is 15.47% wider than its box's half-width allows (its points touch the sides). - let g = build("star6", k, k, &[]).unwrap(); - let bb = g.paths[0].path.bounding_box(); - assert!((bb.x0).abs() < 0.5 && (bb.x1 - k).abs() < 0.5, "{bb:?}"); - } - - #[test] - fn moon_disk_and_drum_match_powerpoints_outlines() { - let k = 216.0; - // Moon, default: the inner edge runs through (0.5, 0.5) and (0.625, 0.2266) of the box. - let g = build("moon", k, k, &[]).unwrap(); - for (x, y) in [(0.5, 0.5), (0.626, 0.2266), (0.0, 0.5), (1.0, 0.0)] { - assert!(on_path(&g, Point::new(k * x, k * y), 0.5), "moon ({x}, {y})"); - } - // 300 × 120 at 30%: its inner edge reaches 30% of the width in. - let g = build("moon", 300.0, 120.0, &[30000.0]).unwrap(); - assert!(on_path(&g, Point::new(90.0, 60.0), 0.5)); - // Magnetic disk ends are ellipses h/6 high, drum ends w/6 across. - let g = build("flowChartMagneticDisk", k, k, &[]).unwrap(); - assert!(on_path(&g, Point::new(k, k / 6.0), 0.01) && on_path(&g, Point::new(k / 2.0, k / 3.0), 0.5)); - let g = build("flowChartMagneticDrum", k, k, &[]).unwrap(); - assert!(on_path(&g, Point::new(k / 6.0, 0.0), 0.01) && on_path(&g, Point::new(0.0, k / 2.0), 0.01)); - } - - #[test] - fn the_cloud_fills_its_box_at_any_aspect_and_has_creases() { - use kurbo::Shape as _; - for (w, h) in [(200.0, 200.0), (300.0, 100.0), (60.0, 180.0)] { - let g = build("cloud", w, h, &[]).unwrap(); - let bb = g.paths[0].path.bounding_box(); - assert!(bb.x0.abs() < w * 0.02 && (bb.x1 - w).abs() < w * 0.02, "{w}×{h}: {bb:?}"); - assert!(bb.y0.abs() < h * 0.02 && (bb.y1 - h).abs() < h * 0.02, "{w}×{h}: {bb:?}"); - assert_eq!(g.paths.len(), 2, "outline and creases"); - assert!(g.paths[1].stroke && g.paths[1].fill == FillMode::None); - } - } - - #[test] - fn spec_arcs_take_angles_as_directions_from_the_centre() { - // A DrawingML arc of a 2:1 ellipse from 45° to 90°: 45° is the direction (1, 1) from the - // centre, which on that ellipse is the point (r/√5)·(2, 1)… scaled so it lies on it. - let mut b = Pb::new(); - b.m(0.0, 0.0).arc_spec(200.0, 100.0, 45.0, 45.0); - let p = b.take(); - let end = p.elements().last().and_then(|e| match e { - kurbo::PathEl::CurveTo(_, _, q) => Some(*q), - _ => None, - }); - // Centre: the start sits at direction 45°, i.e. at (k, k) from it with (k/200)² + (k/100)² = 1. - let k = 1.0 / (1.0f64 / 40000.0 + 1.0 / 10000.0).sqrt(); - let c = Point::new(-k, -k); - let q = end.unwrap(); - assert!((q.x - c.x).abs() < 1e-6 && (q.y - (c.y + 100.0)).abs() < 1e-6, "{q:?} vs centre {c:?}"); - } - - #[test] - fn brackets_and_braces_fill_out_to_their_straight_side() { - // PowerPoint fills a bracket or brace (when it has a fill) between its curve and the - // straight side it opens to; only the curve is outlined. - for name in ["leftBracket", "rightBracket", "leftBrace", "rightBrace"] { - let g = build(name, 40.0, 100.0, &[]).unwrap(); - let filled: Vec<_> = g.paths.iter().filter(|p| p.fill != FillMode::None).collect(); - assert_eq!(filled.len(), 1, "{name}"); - assert!(!filled[0].stroke, "{name}: the filled area is not outlined"); - let area = filled[0].path.area().abs(); - assert!(area > 40.0 * 100.0 * 0.4 && area < 40.0 * 100.0, "{name}: {area}"); - assert!(g.paths.iter().any(|p| p.stroke && p.fill == FillMode::None), "{name}: the curve is outlined"); - } - } - - /// The end points of a path's move/line elements, in order. - fn vertices(p: &BezPath) -> Vec { - p.elements() - .iter() - .filter_map(|e| match e { - kurbo::PathEl::MoveTo(q) | kurbo::PathEl::LineTo(q) => Some(*q), - _ => None, - }) - .collect() - } - - #[test] - fn wedge_callout_mirrors_with_its_tip() { - // A tip below and right of the centre draws the mirror image of the same tip on the left; - // the same for a tip right of the centre, above or below it. presetShapeDefinitions puts - // the wedge's base at 2..5 twelfths of the edge on the near side of the centre and at - // 7..10 twelfths on the far side. - let (w, h) = (240.0, 120.0); - // Sorted and deduplicated: the outline closes on the corner it starts from, which the - // mirror moves to the opposite corner. - let sorted = |mut v: Vec| { - v.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))); - v.dedup_by(|a, b| (*a - *b).hypot() < 1e-9); - v - }; - for (right, left, flip_x) in [([10000.0, 80000.0], [-10000.0, 80000.0], true), ([80000.0, 10000.0], [80000.0, -10000.0], false)] { - let a = vertices(&build("wedgeRectCallout", w, h, &right).unwrap().paths[0].path); - let b = vertices(&build("wedgeRectCallout", w, h, &left).unwrap().paths[0].path); - let b: Vec = b.iter().map(|p| if flip_x { Point::new(w - p.x, p.y) } else { Point::new(p.x, h - p.y) }).collect(); - let (a, b) = (sorted(a), sorted(b)); - assert_eq!(a.len(), b.len(), "{right:?}: {a:?} vs mirrored {left:?}: {b:?}"); - for (p, q) in a.iter().zip(&b) { - assert!((*p - *q).hypot() < 1e-6, "{right:?}: {a:?}\n is not the mirror of {left:?}: {b:?}"); - } - } - // The bottom wedge of a tip right of the centre leaves the edge at 7/12 and 10/12 of the width. - let a = vertices(&build("wedgeRectCallout", w, h, &[10000.0, 80000.0]).unwrap().paths[0].path); - for x in [w * 7.0 / 12.0, w * 10.0 / 12.0] { - assert!(a.iter().any(|p| (p.x - x).abs() < 1e-6 && (p.y - h).abs() < 1e-6), "no wedge corner at ({x}, {h}): {a:?}"); - } - } -} +mod tests; diff --git a/crates/geom/src/preset/tests.rs b/crates/geom/src/preset/tests.rs new file mode 100644 index 00000000..dc18dcdb --- /dev/null +++ b/crates/geom/src/preset/tests.rs @@ -0,0 +1,420 @@ +use super::*; +use kurbo::Shape; + +#[test] +fn every_catalog_preset_builds_within_bounds() { + for p in CATALOG { + for (w, h) in [(200.0, 100.0), (100.0, 200.0), (50.0, 50.0), (0.0, 0.0), (1.0, 300.0)] { + let g = build(p.name, w, h, &[]).unwrap_or_else(|| panic!("{} did not build", p.name)); + assert!(!g.paths.is_empty(), "{} has no paths", p.name); + for s in &g.paths { + let bb = s.path.bounding_box(); + if w > 0.0 && h > 0.0 && !p.name.contains("Connector") && !p.name.contains("allout") { + let tol = w.max(h) * 0.35 + 1.0; + assert!(bb.x0 >= -tol && bb.y0 >= -tol && bb.x1 <= w + tol && bb.y1 <= h + tol, "{} {w}x{h} out of bounds: {bb:?}", p.name); + } + assert!(bb.x0.is_finite() && bb.y1.is_finite(), "{} produced non-finite geometry", p.name); + } + assert!(g.text_rect.x0.is_finite() && g.text_rect.y1.is_finite(), "{}", p.name); + } + } +} + +#[test] +fn hostile_adjust_values_never_panic() { + for p in CATALOG { + for v in [f64::NAN, f64::INFINITY, -1e12, 1e12, -1.0, 0.0] { + let adj = vec![v; 6]; + let g = build(p.name, 120.0, 80.0, &adj); + assert!(g.is_some(), "{}", p.name); + } + } + assert!(build("rect", f64::NAN, 10.0, &[]).is_none()); + assert!(build("noSuchShape", 10.0, 10.0, &[]).is_none()); +} + +#[test] +fn catalog_names_are_unique() { + let mut names: Vec<&str> = CATALOG.iter().map(|p| p.name).collect(); + names.sort_unstable(); + let n = names.len(); + names.dedup(); + assert_eq!(n, names.len()); + assert!(n >= 140, "catalog has {n} presets"); +} + +#[test] +fn round_rect_handle_maps_back() { + let g = build("roundRect", 200.0, 100.0, &[16667.0]).unwrap(); + let h = g.handles.first().unwrap(); + assert!((h.value_at(h.pos) - 16667.0).abs() <= 1.0); + assert_eq!(h.value_at(Point::new(-50.0, 0.0)), 0.0); + assert_eq!(h.value_at(Point::new(500.0, 0.0)), 50000.0); +} + +#[test] +fn partial_circle_angle_handles_map_back() { + // Start 0° sits at the right of the ellipse, end 270° at its top. + for name in ["pie", "chord", "arc"] { + let g = build(name, 200.0, 100.0, &[0.0, 16200000.0]).unwrap(); + assert_eq!(g.handles.iter().map(|h| h.index).collect::>(), vec![0, 1], "{name}"); + let (s, e) = (&g.handles[0], &g.handles[1]); + assert!((s.pos - Point::new(200.0, 50.0)).hypot() < 1e-6, "{name} {:?}", s.pos); + assert!((e.pos - Point::new(100.0, 0.0)).hypot() < 1e-6, "{name} {:?}", e.pos); + assert_eq!(s.value_at(s.pos), 0.0, "{name}"); + assert_eq!(e.value_at(e.pos), 16200000.0, "{name}"); + // Dragging to the bottom of the ellipse gives 90°. + assert_eq!(s.value_at(Point::new(100.0, 100.0)), 5400000.0, "{name}"); + // Off-axis angles on a non-square ellipse: the visual 45° ray meets the 200×100 + // ellipse at offset (√2000, √2000) from the centre. + let k = 2000f64.sqrt(); + let g = build(name, 200.0, 100.0, &[2700000.0, 8100000.0]).unwrap(); + let (s, e) = (&g.handles[0], &g.handles[1]); + assert!((s.pos - Point::new(100.0 + k, 50.0 + k)).hypot() < 1e-6, "{name} {:?}", s.pos); + assert!((e.pos - Point::new(100.0 - k, 50.0 + k)).hypot() < 1e-6, "{name} {:?}", e.pos); + for (deg, h) in [(45.0, s), (135.0, e)] { + let on = ((h.pos.x - 100.0) / 100.0).powi(2) + ((h.pos.y - 50.0) / 50.0).powi(2); + assert!((on - 1.0).abs() < 1e-9, "{name} {deg}° off ellipse: {:?}", h.pos); + assert!((h.value_at(h.pos) - deg * 60000.0).abs() <= 1.0, "{name} {deg}°: {}", h.value_at(h.pos)); + } + } + for deg in [45.0, 135.0, 200.0, 315.0] { + let g = build("blockArc", 200.0, 100.0, &[deg * 60000.0, 0.0, 25000.0]).unwrap(); + let start = g.handles.iter().find(|h| h.index == 0).unwrap(); + // Thickness 25 → the handle runs on the 87.5×37.5 mid-ring ellipse. + let on = ((start.pos.x - 100.0) / 87.5).powi(2) + ((start.pos.y - 50.0) / 37.5).powi(2); + assert!((on - 1.0).abs() < 1e-9, "blockArc {deg}° off ring: {:?}", start.pos); + let d = start.pos - Point::new(100.0, 50.0); + assert!((d.y.atan2(d.x).to_degrees().rem_euclid(360.0) - deg).abs() < 1e-6, "blockArc {deg}°: {:?}", start.pos); + assert!((start.value_at(start.pos) - deg * 60000.0).abs() <= 1.0, "blockArc {deg}°"); + } + let g = build("blockArc", 200.0, 100.0, &[10800000.0, 0.0, 25000.0]).unwrap(); + let start = g.handles.iter().find(|h| h.index == 0).unwrap(); + assert_eq!(start.value_at(start.pos), 10800000.0); + let thick = g.handles.iter().find(|h| h.index == 2).unwrap(); + assert!((thick.value_at(thick.pos) - 25000.0).abs() <= 1.0); +} + +#[test] +fn presets_with_adjust_values_expose_handles() { + // Presets whose adjust values have no handle of their own yet. + const NO_HANDLES: &[&str] = &[ + "curvedConnector3", + "snip2SameRect", + "snip2DiagRect", + "snipRoundRect", + "round1Rect", + "round2SameRect", + "round2DiagRect", + "teardrop", + "halfFrame", + "corner", + "diagStripe", + "foldedCorner", + "smileyFace", + "moon", + "gear6", + "gear9", + "bracketPair", + "bracePair", + "leftBracket", + "rightBracket", + "leftBrace", + "rightBrace", + "leftRightArrow", + "upDownArrow", + "quadArrow", + "leftRightUpArrow", + "bentArrow", + "uturnArrow", + "curvedRightArrow", + "curvedLeftArrow", + "curvedUpArrow", + "curvedDownArrow", + "leftUpArrow", + "bentUpArrow", + "circularArrow", + "leftCircularArrow", + "leftRightCircularArrow", + "swooshArrow", + "rightArrowCallout", + "downArrowCallout", + "leftArrowCallout", + "upArrowCallout", + "leftRightArrowCallout", + "upDownArrowCallout", + "quadArrowCallout", + "mathPlus", + "mathMinus", + "mathMultiply", + "mathDivide", + "mathEqual", + "mathNotEqual", + "ribbon2", + "ribbon", + "ellipseRibbon2", + "ellipseRibbon", + "leftRightRibbon", + "verticalScroll", + "horizontalScroll", + "wave", + "doubleWave", + "borderCallout1", + "borderCallout2", + "borderCallout3", + "accentCallout1", + "accentCallout2", + "accentCallout3", + "callout1", + "callout2", + "callout3", + "accentBorderCallout1", + "accentBorderCallout2", + "accentBorderCallout3", + ]; + for p in CATALOG { + if p.defaults.is_empty() || NO_HANDLES.contains(&p.name) { + continue; + } + let g = build(p.name, 200.0, 100.0, &[]).unwrap(); + assert!(!g.handles.is_empty(), "{} has adjust values but no handles", p.name); + } +} + +#[test] +fn rect_area_and_ellipse_area() { + let g = build("rect", 100.0, 50.0, &[]).unwrap(); + assert!((g.paths[0].path.area().abs() - 5000.0).abs() < 1e-6); + let e = build("ellipse", 100.0, 50.0, &[]).unwrap(); + let expect = std::f64::consts::PI * 50.0 * 25.0; + assert!((e.paths[0].path.area().abs() - expect).abs() / expect < 0.01); +} + +#[test] +fn lines_are_open() { + assert!(build("line", 10.0, 10.0, &[]).unwrap().is_open()); + assert!(!build("rect", 10.0, 10.0, &[]).unwrap().is_open()); +} + +fn on_path(g: &Geometry, p: Point, tol: f64) -> bool { + use kurbo::ParamCurveNearest; + g.paths.iter().any(|sp| sp.path.segments().any(|seg| seg.nearest(p, 1e-6).distance_sq <= tol * tol)) +} + +#[test] +fn fixed_shapes_follow_the_standards_coordinates() { + // Points of ECMA-376's definitions (in 21600ths), checked on a 216 × 108 box. + let (w, h) = (216.0, 108.0); + let at = |x: f64, y: f64| Point::new(w * x / 21600.0, h * y / 21600.0); + let g = build("lightningBolt", w, h, &[]).unwrap(); + for (x, y) in [(8472.0, 0.0), (16577.0, 12007.0), (21600.0, 21600.0), (0.0, 3890.0)] { + assert!(on_path(&g, at(x, y), 1e-6), "lightningBolt ({x}, {y})"); + } + let g = build("irregularSeal1", w, h, &[]).unwrap(); + assert!(on_path(&g, at(14522.0, 0.0), 1e-6) && on_path(&g, at(8485.0, 21600.0), 1e-6)); + let g = build("irregularSeal2", w, h, &[]).unwrap(); + assert!(on_path(&g, at(14790.0, 0.0), 1e-6) && on_path(&g, at(4917.0, 21600.0), 1e-6)); + // The heart's point is the middle of the bottom edge, its notch a quarter down. + let g = build("heart", w, h, &[]).unwrap(); + assert!(on_path(&g, Point::new(w / 2.0, h), 1e-6) && on_path(&g, Point::new(w / 2.0, h / 4.0), 1e-6)); + let g = build("flowChartDocument", w, h, &[]).unwrap(); + assert!(on_path(&g, at(21600.0, 17322.0), 1e-6) && on_path(&g, at(0.0, 20172.0), 1e-6)); + // Punched tape: the top edge dips to a fifth of the height a quarter of the way along. + let g = build("flowChartPunchedTape", w, h, &[]).unwrap(); + assert!(on_path(&g, Point::new(w / 4.0, h / 5.0), 1e-3) && on_path(&g, Point::new(w * 0.75, 0.0), 1e-3)); + let g = build("flowChartMultidocument", w, h, &[]).unwrap(); + assert!(on_path(&g, at(2972.0, 0.0), 1e-6) && on_path(&g, at(18595.0, 18022.0), 1e-6)); +} + +#[test] +fn notched_arrow_notch_parallels_the_head() { + // presetShapeDefinitions: the notch's point is at x1 = dy1 · dx2 / hd2, so its edges run + // parallel to the head's (25 on a 200 × 100 arrow at the default adjusts). + for (w, h, adj, x1) in [(200.0, 100.0, [50000.0, 50000.0], 25.0), (300.0, 120.0, [80000.0, 30000.0], 28.8)] { + let g = build("notchedRightArrow", w, h, &adj).unwrap(); + let notch = g.paths[0].path.elements().iter().find_map(|e| match e { + kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) if (p.y - h / 2.0).abs() < 1e-9 && p.x < w / 2.0 => Some(*p), + _ => None, + }); + assert!(notch.is_some_and(|p| (p.x - x1).abs() < 1e-6), "{w}x{h} {adj:?}: notch at {notch:?}, want x = {x1}"); + } +} + +#[test] +fn equation_shapes_match_powerpoints_outlines() { + // Corners read off PowerPoint's own outlines of the presets at their default adjusts. + let g = build("mathMultiply", 216.0, 216.0, &[]).unwrap(); + for (x, y) in [(0.32333, 0.15702), (0.5, 0.33369), (0.84298, 0.32333), (0.66631, 0.5), (0.33369, 0.5)] { + assert!(on_path(&g, Point::new(216.0 * x, 216.0 * y), 0.01), "multiply ({x}, {y})"); + } + let g = build("mathEqual", 300.0, 120.0, &[]).unwrap(); + for (x, y) in [(0.13255, 0.5588), (0.86745, 0.794), (0.13255, 0.206), (0.86745, 0.4412)] { + assert!(on_path(&g, Point::new(300.0 * x, 120.0 * y), 0.01), "equal ({x}, {y})"); + } + let g = build("mathPlus", 216.0, 216.0, &[]).unwrap(); + assert!(on_path(&g, Point::new(216.0 * 0.3824, 216.0 * 0.13255), 0.01) && on_path(&g, Point::new(216.0 * 0.86745, 216.0 * 0.6176), 0.01)); + // A tall "not equal": its slash, leaning at the default 110° from top to bottom, reaches + // past the box's sides exactly as far as PowerPoint's (-22.242 to 122.242). + let g = build("mathNotEqual", 100.0, 260.0, &[]).unwrap(); + use kurbo::Shape as _; + let bb = g.paths[0].path.bounding_box(); + assert!((bb.x0 + 22.242).abs() < 0.01 && (bb.x1 - 122.242).abs() < 0.01 && bb.y0.abs() < 1e-6 && (bb.y1 - 260.0).abs() < 1e-6, "{bb:?}"); +} + +#[test] +fn sun_and_waves_match_powerpoints_outlines() { + // Points read off PowerPoint's outlines of the presets (216 pt square, default adjusts). + let k = 216.0; + let g = build("sun", k, k, &[]).unwrap(); + for (x, y) in [(0.85352, 0.14644), (0.76161, 0.33987), (0.66013, 0.23839), (0.42825, 0.79823), (0.20177, 0.57175), (0.75, 0.5), (0.5, 0.25)] { + assert!(on_path(&g, Point::new(k * x, k * y), 0.02), "sun ({x}, {y})"); + } + let g = build("wave", k, k, &[]).unwrap(); + for (x, y) in [(0.21875, 0.00484), (1.0, 0.125), (1.0, 0.875), (0.0, 0.875)] { + assert!(on_path(&g, Point::new(k * x, k * y), 0.05), "wave ({x}, {y})"); + } + // Shifted: a wave of 300 × 120 with adj (20000, 8000) starts 0.16 of the width in. + let g = build("wave", 300.0, 120.0, &[20000.0, 8000.0]).unwrap(); + for (x, y) in [(0.18375, 0.00774), (0.84, 0.2), (1.0, 0.8), (0.16, 0.8)] { + assert!(on_path(&g, Point::new(300.0 * x, 120.0 * y), 0.05), "shifted wave ({x}, {y})"); + } +} + +#[test] +fn star_text_rectangles_follow_the_standard() { + // star5 at its default 19098 in a 144 pt square: the inner pentagon, about 0.38 of the + // width (PowerPoint wraps "Shape 3" at 18 pt there), from y ≈ 55 to ≈ 110. + let g = build("star5", 144.0, 144.0, &[]).unwrap(); + let t = g.text_rect; + assert!((t.width() - 55.0).abs() < 1.0, "{t:?}"); + assert!((t.y0 - 55.0).abs() < 1.0 && (t.y1 - 110.0).abs() < 1.0, "{t:?}"); + // star4 at 12500: the square through its inner points' 45° corners. + let g = build("star4", 144.0, 144.0, &[]).unwrap(); + assert!((g.text_rect.width() - 72.0 * 0.25 * 2f64.sqrt()).abs() < 0.5, "{:?}", g.text_rect); +} + +#[test] +fn stars_are_stretched_like_the_standards() { + // star5 in a 216 pt square: top point at the top middle, side points at 18 degrees on an + // ellipse 5.146% wider and 10.557% taller, centred 10.557% below the middle; its bottom + // points reach the box's bottom edge. + let k = 216.0; + let g = build("star5", k, k, &[]).unwrap(); + let (rx, ry, cy) = (k / 2.0 * 1.05146, k / 2.0 * 1.10557, k / 2.0 * 1.10557); + let a = 18f64.to_radians(); + assert!(on_path(&g, Point::new(k / 2.0, 0.0), 1e-6)); + assert!(on_path(&g, Point::new(k / 2.0 + rx * a.cos(), cy - ry * a.sin()), 1e-6)); + use kurbo::Shape as _; + let bb = g.paths[0].path.bounding_box(); + assert!((bb.x0).abs() < 0.5 && (bb.x1 - k).abs() < 0.5 && (bb.y1 - k).abs() < 0.5, "{bb:?}"); + // star6 is 15.47% wider than its box's half-width allows (its points touch the sides). + let g = build("star6", k, k, &[]).unwrap(); + let bb = g.paths[0].path.bounding_box(); + assert!((bb.x0).abs() < 0.5 && (bb.x1 - k).abs() < 0.5, "{bb:?}"); +} + +#[test] +fn moon_disk_and_drum_match_powerpoints_outlines() { + let k = 216.0; + // Moon, default: the inner edge runs through (0.5, 0.5) and (0.625, 0.2266) of the box. + let g = build("moon", k, k, &[]).unwrap(); + for (x, y) in [(0.5, 0.5), (0.626, 0.2266), (0.0, 0.5), (1.0, 0.0)] { + assert!(on_path(&g, Point::new(k * x, k * y), 0.5), "moon ({x}, {y})"); + } + // 300 × 120 at 30%: its inner edge reaches 30% of the width in. + let g = build("moon", 300.0, 120.0, &[30000.0]).unwrap(); + assert!(on_path(&g, Point::new(90.0, 60.0), 0.5)); + // Magnetic disk ends are ellipses h/6 high, drum ends w/6 across. + let g = build("flowChartMagneticDisk", k, k, &[]).unwrap(); + assert!(on_path(&g, Point::new(k, k / 6.0), 0.01) && on_path(&g, Point::new(k / 2.0, k / 3.0), 0.5)); + let g = build("flowChartMagneticDrum", k, k, &[]).unwrap(); + assert!(on_path(&g, Point::new(k / 6.0, 0.0), 0.01) && on_path(&g, Point::new(0.0, k / 2.0), 0.01)); +} + +#[test] +fn the_cloud_fills_its_box_at_any_aspect_and_has_creases() { + use kurbo::Shape as _; + for (w, h) in [(200.0, 200.0), (300.0, 100.0), (60.0, 180.0)] { + let g = build("cloud", w, h, &[]).unwrap(); + let bb = g.paths[0].path.bounding_box(); + assert!(bb.x0.abs() < w * 0.02 && (bb.x1 - w).abs() < w * 0.02, "{w}×{h}: {bb:?}"); + assert!(bb.y0.abs() < h * 0.02 && (bb.y1 - h).abs() < h * 0.02, "{w}×{h}: {bb:?}"); + assert_eq!(g.paths.len(), 2, "outline and creases"); + assert!(g.paths[1].stroke && g.paths[1].fill == FillMode::None); + } +} + +#[test] +fn spec_arcs_take_angles_as_directions_from_the_centre() { + // A DrawingML arc of a 2:1 ellipse from 45° to 90°: 45° is the direction (1, 1) from the + // centre, which on that ellipse is the point (r/√5)·(2, 1)… scaled so it lies on it. + let mut b = Pb::new(); + b.m(0.0, 0.0).arc_spec(200.0, 100.0, 45.0, 45.0); + let p = b.take(); + let end = p.elements().last().and_then(|e| match e { + kurbo::PathEl::CurveTo(_, _, q) => Some(*q), + _ => None, + }); + // Centre: the start sits at direction 45°, i.e. at (k, k) from it with (k/200)² + (k/100)² = 1. + let k = 1.0 / (1.0f64 / 40000.0 + 1.0 / 10000.0).sqrt(); + let c = Point::new(-k, -k); + let q = end.unwrap(); + assert!((q.x - c.x).abs() < 1e-6 && (q.y - (c.y + 100.0)).abs() < 1e-6, "{q:?} vs centre {c:?}"); +} + +#[test] +fn brackets_and_braces_fill_out_to_their_straight_side() { + // PowerPoint fills a bracket or brace (when it has a fill) between its curve and the + // straight side it opens to; only the curve is outlined. + for name in ["leftBracket", "rightBracket", "leftBrace", "rightBrace"] { + let g = build(name, 40.0, 100.0, &[]).unwrap(); + let filled: Vec<_> = g.paths.iter().filter(|p| p.fill != FillMode::None).collect(); + assert_eq!(filled.len(), 1, "{name}"); + assert!(!filled[0].stroke, "{name}: the filled area is not outlined"); + let area = filled[0].path.area().abs(); + assert!(area > 40.0 * 100.0 * 0.4 && area < 40.0 * 100.0, "{name}: {area}"); + assert!(g.paths.iter().any(|p| p.stroke && p.fill == FillMode::None), "{name}: the curve is outlined"); + } +} + +/// The end points of a path's move/line elements, in order. +fn vertices(p: &BezPath) -> Vec { + p.elements() + .iter() + .filter_map(|e| match e { + kurbo::PathEl::MoveTo(q) | kurbo::PathEl::LineTo(q) => Some(*q), + _ => None, + }) + .collect() +} + +#[test] +fn wedge_callout_mirrors_with_its_tip() { + // A tip below and right of the centre draws the mirror image of the same tip on the left; + // the same for a tip right of the centre, above or below it. presetShapeDefinitions puts + // the wedge's base at 2..5 twelfths of the edge on the near side of the centre and at + // 7..10 twelfths on the far side. + let (w, h) = (240.0, 120.0); + // Sorted and deduplicated: the outline closes on the corner it starts from, which the + // mirror moves to the opposite corner. + let sorted = |mut v: Vec| { + v.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))); + v.dedup_by(|a, b| (*a - *b).hypot() < 1e-9); + v + }; + for (right, left, flip_x) in [([10000.0, 80000.0], [-10000.0, 80000.0], true), ([80000.0, 10000.0], [80000.0, -10000.0], false)] { + let a = vertices(&build("wedgeRectCallout", w, h, &right).unwrap().paths[0].path); + let b = vertices(&build("wedgeRectCallout", w, h, &left).unwrap().paths[0].path); + let b: Vec = b.iter().map(|p| if flip_x { Point::new(w - p.x, p.y) } else { Point::new(p.x, h - p.y) }).collect(); + let (a, b) = (sorted(a), sorted(b)); + assert_eq!(a.len(), b.len(), "{right:?}: {a:?} vs mirrored {left:?}: {b:?}"); + for (p, q) in a.iter().zip(&b) { + assert!((*p - *q).hypot() < 1e-6, "{right:?}: {a:?}\n is not the mirror of {left:?}: {b:?}"); + } + } + // The bottom wedge of a tip right of the centre leaves the edge at 7/12 and 10/12 of the width. + let a = vertices(&build("wedgeRectCallout", w, h, &[10000.0, 80000.0]).unwrap().paths[0].path); + for x in [w * 7.0 / 12.0, w * 10.0 / 12.0] { + assert!(a.iter().any(|p| (p.x - x).abs() < 1e-6 && (p.y - h).abs() < 1e-6), "no wedge corner at ({x}, {h}): {a:?}"); + } +}