Files
wordcraft/crates/engine/src/cmd/ink_shape.rs
T
1f0aeb4626 Ink to Shape: two-stroke arrows and closed-triangle heads (gap 12) (#1778)
* ink-arrows-2: WIP

* ink-arrows-2: Ink to Shape makes two-stroke arrows and closed-triangle heads

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ink-arrows-2: review fixes — recognise each stroke once, drawing-order pairing, arrow rect

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 04:24:15 -04:00

971 lines
48 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.
//! Draw › Ink to Shape: a small geometric recogniser that tells whether a hand-drawn stroke is a
//! straight line, a rectangle (or square), an ellipse (or circle), a triangle or another polygon
//! of up to eight corners. Our own fit: an open stroke is a line when it stays close to the chord
//! between its ends; a closed one is an ellipse when its points sit on the ellipse inscribed in
//! its bounds (low RMS of the normalised radius), else a polygon from its corners (the closed
//! path simplified with Ramer–Douglas–Peucker, near-straight and crowded corners merged) when
//! that polygon stays close to every point. An arrow is a line-like shaft plus a head drawn in the
//! same stroke (shaft first or head first): two barbs, or a small closed triangle round the tip.
//! [`pair_arrow`] makes one arrow of two strokes: a line, and a head (an open V or a small closed
//! triangle) whose apex sits at one of its ends. Anything else (scribbles, curves, letters) stays ink.
use wordcraft_doc::freeform::MAX_POINTS;
/// A shape recognised in a stroke, in the stroke's own coordinates.
#[derive(Clone, Debug, PartialEq)]
pub enum InkShape {
/// A straight line from `a` to `b`.
Line { a: [f32; 2], b: [f32; 2] },
/// A straight arrow from the tail `a` to the tip `b`; the heads are the `head_at_*` flags
/// (recognised strokes have one, at `b`).
Arrow { a: [f32; 2], b: [f32; 2], head_at_end: bool, head_at_start: bool },
/// An upright rectangle: top-left and size (equal sides when drawn nearly square).
Rectangle { x: f32, y: f32, w: f32, h: f32 },
/// An upright ellipse in this box (a circle when drawn nearly round).
Ellipse { x: f32, y: f32, w: f32, h: f32 },
/// A closed polygon through its 3–8 corners (a triangle has three).
Polygon(Vec<[f32; 2]>),
}
impl InkShape {
/// What it is, for messages and command results.
pub fn name(&self) -> &'static str {
match self {
InkShape::Line { .. } => "line",
InkShape::Arrow { .. } => "arrow",
InkShape::Rectangle { w, h, .. } if w == h => "square",
InkShape::Rectangle { .. } => "rectangle",
InkShape::Ellipse { w, h, .. } if w == h => "circle",
InkShape::Ellipse { .. } => "ellipse",
InkShape::Polygon(c) if c.len() == 3 => "triangle",
InkShape::Polygon(_) => "polygon",
}
}
/// Its bounds: left, top, width, height.
pub fn bounds(&self) -> [f32; 4] {
match self {
InkShape::Rectangle { x, y, w, h } | InkShape::Ellipse { x, y, w, h } => [*x, *y, *w, *h],
InkShape::Line { a, b } | InkShape::Arrow { a, b, .. } => bbox(&[*a, *b]),
InkShape::Polygon(c) => bbox(c),
}
}
}
/// Smallest stroke (its longer side, points) that is turned into a shape: a tap or a tick stays ink.
const MIN_SIZE: f32 = 6.0;
/// Most corners a polygon may have.
pub const MAX_CORNERS: usize = 8;
#[cfg(test)]
thread_local! {
/// How many times [`recognise`] ran on this thread (tests check the work stays bounded).
pub static RECOGNISED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// The shape `pts` draws, if it is one.
pub fn recognise(pts: &[[f32; 2]]) -> Option<InkShape> {
#[cfg(test)]
RECOGNISED.with(|c| c.set(c.get().saturating_add(1)));
let p = clean(pts);
let (first, last) = (*p.first()?, *p.last()?);
if p.len() < 2 {
return None;
}
let [x0, y0, w, h] = bbox(&p);
let size = w.max(h);
let diag = w.hypot(h);
if size < MIN_SIZE {
return None;
}
let length: f32 = p.windows(2).map(|s| s.first().zip(s.get(1)).map_or(0.0, |(a, b)| dist(*a, *b))).sum();
let gap = dist(first, last);
// A line: the stroke barely leaves the chord between its ends and doesn't double back.
if gap > 0.85 * length && gap >= MIN_SIZE {
let off = p.iter().map(|q| seg_distance(first, last, *q)).fold(0.0, f32::max);
if off <= (0.06 * gap).max(1.5) {
return Some(InkShape::Line { a: first, b: last });
}
}
// An arrow: a shaft and a head (both barbs, or a closed triangle), drawn in one stroke
// (either end first).
let rev: Vec<[f32; 2]> = p.iter().rev().copied().collect();
if let Some(arrow) = arrow(&p).or_else(|| arrow(&rev)).or_else(|| closed_arrow(&p)).or_else(|| closed_arrow(&rev)) {
return Some(arrow);
}
// Other shapes are closed: the stroke ends near where it began and goes around.
if gap > 0.2 * size || length < 2.0 * size {
return None;
}
// Ellipse: the points sit on the ellipse inscribed in their bounds.
let ellipse_rms = ellipse_rms(&p, x0, y0, w, h);
if w > 0.2 * size && h > 0.2 * size && ellipse_rms < 0.06 {
let (x, y, w, h) = snap_square(x0, y0, w, h);
return Some(InkShape::Ellipse { x, y, w, h });
}
// Polygon: the corners of the closed path, if they describe it.
let corners = corners(&p, diag);
let fits = corners.len() >= 3 && p.iter().all(|q| polygon_distance(&corners, *q) <= 0.08 * diag);
if fits && corners.len() == 4 && upright(&corners) {
let [x, y, w, h] = bbox(&corners);
let (x, y, w, h) = snap_square(x, y, w, h);
return Some(InkShape::Rectangle { x, y, w, h });
}
if fits && corners.len() <= MAX_CORNERS {
return Some(InkShape::Polygon(corners));
}
// A wobbly round stroke is still an ellipse.
(w > 0.2 * size && h > 0.2 * size && ellipse_rms < 0.1).then(|| {
let (x, y, w, h) = snap_square(x0, y0, w, h);
InkShape::Ellipse { x, y, w, h }
})
}
/// Barb length limits, as a fraction of the shaft, and the angle each barb makes with the shaft
/// (degrees).
const BARB_LEN: (f32, f32) = (0.05, 0.5);
const BARB_ANGLE: (f32, f32) = (15.0, 75.0);
/// An arrow drawn shaft first: the shaft runs from the start to the tip (the point farthest from
/// the start) and stays near its chord like a line does; then the pen draws one barb, comes back
/// to the tip and draws the other. The barbs must leave the tip on opposite sides of the shaft at
/// similar angles and lengths. (A stroke drawn head first is tried reversed by the caller.)
fn arrow(p: &[[f32; 2]]) -> Option<InkShape> {
let tail = *p.first()?;
let (ti, tip, shaft) = shaft_tip(p)?;
// The head: the stroke after the tip leaves it twice, once for each barb.
let head = p.get(ti..)?;
let reach = head.iter().map(|q| dist(tip, *q)).fold(0.0, f32::max);
let (mut barbs, mut out, mut gap): (Vec<(f32, [f32; 2])>, bool, f32) = (Vec::new(), false, f32::INFINITY);
for q in head {
let d = dist(tip, *q);
if d > 0.25 * reach {
if !out {
if barbs.len() == 2 {
return None;
}
barbs.push((d, *q));
out = true;
} else if let Some(b) = barbs.last_mut().filter(|b| d > b.0) {
*b = (d, *q);
}
} else {
out = false;
if barbs.len() == 1 {
gap = gap.min(d);
}
}
}
let &[(l1, b1), (l2, b2)] = barbs.as_slice() else { return None };
// The pen returns to the tip between the barbs.
if gap > 0.35 * l1.min(l2) || shaft <= 0.0 {
return None;
}
head_fits(tail, tip, tip, b1, b2).then_some(InkShape::Arrow { a: tail, b: tip, head_at_end: true, head_at_start: false })
}
/// An arrow with a closed triangle head, drawn shaft first: the shaft runs from the start to the
/// tip like [`arrow`]'s, then the pen goes round a small triangle (tip, one barb end, the other)
/// and ends back at the tip. (Drawn head first, the caller tries it reversed.)
fn closed_arrow(p: &[[f32; 2]]) -> Option<InkShape> {
let tail = *p.first()?;
let (ti, tip, _) = shaft_tip(p)?;
let head = p.get(ti..)?;
// The corners: the point farthest from the tip, then the one farthest from that side.
let b1 = head.iter().copied().fold(tip, |m, q| if dist(tip, q) > dist(tip, m) { q } else { m });
let b2 = head.iter().copied().fold(tip, |m, q| if seg_distance(tip, b1, q) > seg_distance(tip, b1, m) { q } else { m });
let (l1, l2) = (dist(tip, b1), dist(tip, b2));
if l1.min(l2) <= 0.0 || dist(*head.last()?, tip) > 0.35 * l1.min(l2) {
return None;
}
// Every point of the head is on the triangle.
let tri = [tip, b1, b2];
if head.iter().any(|q| polygon_distance(&tri, *q) > (0.15 * l1).max(1.5)) {
return None;
}
head_fits(tail, tip, tip, b1, b2).then_some(InkShape::Arrow { a: tail, b: tip, head_at_end: true, head_at_start: false })
}
/// Whether a head whose barbs run from `apex` to `b1` and `b2` fits a shaft from `tail` to `tip`:
/// barbs 5–50 % of the shaft, each 15–75° off the way back along it, on opposite sides of it,
/// and of similar length and angle.
fn head_fits(tail: [f32; 2], tip: [f32; 2], apex: [f32; 2], b1: [f32; 2], b2: [f32; 2]) -> bool {
let shaft = dist(tail, tip);
let (l1, l2) = (dist(apex, b1), dist(apex, b2));
if !(shaft > 0.0 && l1 > 0.0 && l2 > 0.0) {
return false;
}
// Each barb as a vector from the apex, against the direction back along the shaft.
let back = [tail[0] - tip[0], tail[1] - tip[1]];
let angle_side = |e: [f32; 2], len: f32| {
let u = [e[0] - apex[0], e[1] - apex[1]];
let angle = ((back[0] * u[0] + back[1] * u[1]) / (shaft * len)).clamp(-1.0, 1.0).acos().to_degrees();
(angle, back[0] * u[1] - back[1] * u[0])
};
let ((a1, s1), (a2, s2)) = (angle_side(b1, l1), angle_side(b2, l2));
let ok = |l: f32, a: f32| (BARB_LEN.0 * shaft..=BARB_LEN.1 * shaft).contains(&l) && (BARB_ANGLE.0..=BARB_ANGLE.1).contains(&a);
let similar = l1.max(l2) <= 2.5 * l1.min(l2) && (a1 - a2).abs() <= 35.0;
ok(l1, a1) && ok(l2, a2) && s1 * s2 < 0.0 && similar
}
/// How far a two-stroke arrow's head apex may sit from the line's end, as a fraction of the line.
const PAIR_REACH: f32 = 0.12;
/// What a stroke can be in a two-stroke arrow, worked out once (one recognition): the ends of the
/// straight line it is, if it is one, and the ways it can be an arrowhead on its own.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ArrowParts {
pub line: Option<([f32; 2], [f32; 2])>,
pub heads: Vec<[[f32; 2]; 3]>,
}
/// `pts` as a possible half of a two-stroke arrow (see [`pair_parts`]).
pub fn arrow_parts(pts: &[[f32; 2]]) -> ArrowParts {
let p = clean(pts);
let found = recognise(&p);
let line = match found {
Some(InkShape::Line { a, b }) => Some((a, b)),
_ => None,
};
ArrowParts { line, heads: heads(&p, found.as_ref()) }
}
/// One arrow from two strokes: `line`, a straight stroke, and `head`, an open V (one barb end,
/// the apex, the other end) or a small closed triangle, whose apex is at one end of the line and
/// whose barbs point back along it (the single-stroke head limits). The arrow runs from the
/// line's other end to that end; `None` when they don't make one.
pub fn pair_arrow(line: &[[f32; 2]], head: &[[f32; 2]]) -> Option<InkShape> {
pair_parts(&arrow_parts(line), &arrow_parts(head))
}
/// [`pair_arrow`] on strokes already recognised by [`arrow_parts`]: cheap geometry only.
pub fn pair_parts(line: &ArrowParts, head: &ArrowParts) -> Option<InkShape> {
let (a, b) = line.line?;
let mut best: Option<(f32, InkShape)> = None;
for &[apex, b1, b2] in &head.heads {
for (tail, tip) in [(a, b), (b, a)] {
let off = dist(apex, tip);
let near = off <= (PAIR_REACH * dist(tail, tip)).max(3.0);
if near && best.as_ref().is_none_or(|(d, _)| off < *d) && head_fits(tail, tip, apex, b1, b2) {
best = Some((off, InkShape::Arrow { a: tail, b: tip, head_at_end: true, head_at_start: false }));
}
}
}
best.map(|(_, arrow)| arrow)
}
/// The ways the cleaned points `p` (which recognise as `found`) can be an arrowhead on their own,
/// as `[apex, barb end, barb end]`: each corner of a closed triangle, or the corner of an open V
/// whose two arms are straight.
fn heads(p: &[[f32; 2]], found: Option<&InkShape>) -> Vec<[[f32; 2]; 3]> {
if let Some(InkShape::Polygon(c)) = found
&& let &[x, y, z] = c.as_slice()
{
return vec![[x, y, z], [y, z, x], [z, x, y]];
}
let (Some(&f), Some(&l)) = (p.first(), p.last()) else { return Vec::new() };
// The apex: the point farthest from the chord between the ends.
let Some((ai, apex)) = p.iter().copied().enumerate().fold(None, |m: Option<(usize, [f32; 2])>, (i, q)| match m {
Some((_, b)) if seg_distance(f, l, b) >= seg_distance(f, l, q) => m,
_ => Some((i, q)),
}) else {
return Vec::new();
};
let straight = |a: [f32; 2], b: [f32; 2], arm: Option<&[[f32; 2]]>| {
let len = dist(a, b);
len >= 1.0 && arm.is_some_and(|arm| arm.iter().all(|q| seg_distance(a, b, *q) <= (0.12 * len).max(1.5)))
};
if ai == 0 || ai + 1 >= p.len() || !straight(f, apex, p.get(..=ai)) || !straight(apex, l, p.get(ai..)) {
return Vec::new();
}
vec![[apex, f, l]]
}
/// Finite, not absurdly far points only (at most [`MAX_POINTS`]), without repeats.
fn clean(pts: &[[f32; 2]]) -> Vec<[f32; 2]> {
let mut p: Vec<[f32; 2]> = Vec::with_capacity(pts.len().min(MAX_POINTS));
for q in pts.iter().take(MAX_POINTS).filter(|[x, y]| x.is_finite() && y.is_finite() && x.abs() < 1e6 && y.abs() < 1e6) {
if p.last() != Some(q) {
p.push(*q);
}
}
p
}
/// A shaft drawn from the start of `p`: the index of its tip, the tip and the shaft's length;
/// `None` when it is too short or not straight.
fn shaft_tip(p: &[[f32; 2]]) -> Option<(usize, [f32; 2], f32)> {
let tail = *p.first()?;
// The tip: start at the first point about as far from the start as any (a return that
// overshoots it by a point or two must not move it), then follow the stroke on while it still
// moves away from the start (densely sampled strokes have shaft points inside that window).
// The first barb heads back, so the walk stops at the real tip and never reaches an overshoot;
// jitter of under half a point doesn't stop it early.
let dmax = p.iter().map(|q| dist(tail, *q)).fold(0.0, f32::max);
let mut ti = p.iter().position(|q| dist(tail, *q) >= dmax - (0.03 * dmax).max(2.0))?;
let mut best = dist(tail, *p.get(ti)?);
for (i, q) in p.iter().enumerate().skip(ti + 1) {
let d = dist(tail, *q);
if d >= best {
(ti, best) = (i, d);
} else if d < best - 0.5 {
break;
}
}
let tip = *p.get(ti)?;
let shaft = dist(tail, tip);
if shaft < MIN_SIZE || p.get(..=ti)?.iter().any(|q| seg_distance(tail, tip, *q) > (0.06 * shaft).max(1.5)) {
return None;
}
Some((ti, tip, shaft))
}
/// Left, top, width and height of `pts` (zero for none).
fn bbox(pts: &[[f32; 2]]) -> [f32; 4] {
let Some(f) = pts.first() else { return [0.0; 4] };
let (mut x0, mut y0, mut x1, mut y1) = (f[0], f[1], f[0], f[1]);
for [x, y] in pts {
(x0, y0, x1, y1) = (x0.min(*x), y0.min(*y), x1.max(*x), y1.max(*y));
}
[x0, y0, x1 - x0, y1 - y0]
}
fn dist(a: [f32; 2], b: [f32; 2]) -> f32 {
(b[0] - a[0]).hypot(b[1] - a[1])
}
/// Distance from `p` to the segment `a`–`b`.
fn seg_distance(a: [f32; 2], b: [f32; 2], p: [f32; 2]) -> f32 {
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let len2 = dx * dx + dy * dy;
let t = if len2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 };
dist([a[0] + t * dx, a[1] + t * dy], p)
}
/// Distance from `p` to the closed polygon through `c`.
fn polygon_distance(c: &[[f32; 2]], p: [f32; 2]) -> f32 {
let n = c.len();
(0..n).filter_map(|i| Some(seg_distance(*c.get(i)?, *c.get((i + 1) % n)?, p))).fold(f32::INFINITY, f32::min)
}
/// How far the points stray from the ellipse inscribed in the `w` × `h` box at (`x0`, `y0`): the
/// RMS of each point's normalised radius minus one (0 on the ellipse).
fn ellipse_rms(p: &[[f32; 2]], x0: f32, y0: f32, w: f32, h: f32) -> f32 {
let (rx, ry) = (w / 2.0, h / 2.0);
if rx <= 0.0 || ry <= 0.0 || p.is_empty() {
return f32::INFINITY;
}
let (cx, cy) = (x0 + rx, y0 + ry);
let sum: f32 = p.iter().map(|[x, y]| (((x - cx) / rx).hypot((y - cy) / ry) - 1.0).powi(2)).sum();
(sum / p.len() as f32).sqrt()
}
/// A box drawn nearly square made square (same centre).
fn snap_square(x: f32, y: f32, w: f32, h: f32) -> (f32, f32, f32, f32) {
if (w - h).abs() <= 0.12 * w.max(h) {
let s = (w + h) / 2.0;
(x + (w - s) / 2.0, y + (h - s) / 2.0, s, s)
} else {
(x, y, w, h)
}
}
/// The corners of the closed path through `p`: simplified with Ramer–Douglas–Peucker (split
/// at the point farthest from the start), then corners that barely turn or crowd a neighbour
/// merged away.
fn corners(p: &[[f32; 2]], diag: f32) -> Vec<[f32; 2]> {
let eps = 0.05 * diag;
let Some(&start) = p.first() else { return Vec::new() };
let far = p.iter().enumerate().fold((0, 0.0), |best, (i, q)| if dist(start, *q) > best.1 { (i, dist(start, *q)) } else { best }).0;
let (a, b) = p.split_at(far.min(p.len()));
let mut ring: Vec<[f32; 2]> = rdp(&[a, b.get(..1).unwrap_or(&[])].concat(), eps);
let mut back = rdp(&[b, &[start]].concat(), eps);
// The halves share the far point and the start.
ring.pop();
back.pop();
ring.extend(back);
// Merge until every corner turns sharply and none sits on top of the next.
while ring.len() > 3 {
let n = ring.len();
let turn = |i: usize| -> f32 {
let (Some(a), Some(b), Some(c)) = (ring.get((i + n - 1) % n), ring.get(i), ring.get((i + 1) % n)) else { return 0.0 };
turning(*a, *b, *c)
};
let (i, t) = (0..n).map(|i| (i, turn(i))).fold((0, f32::INFINITY), |m, x| if x.1 < m.1 { x } else { m });
let short = (0..n).find(|&j| ring.get(j).zip(ring.get((j + 1) % n)).is_some_and(|(a, b)| dist(*a, *b) < 0.1 * diag));
if t < 30.0 {
ring.remove(i);
} else if let Some(j) = short {
// Of the two crowded corners keep the sharper.
let k = if turn(j) < turn((j + 1) % n) { j } else { (j + 1) % n };
ring.remove(k);
} else {
break;
}
}
ring
}
/// How far the path turns at `b` coming from `a` and going on to `c`, degrees (0 = straight on).
fn turning(a: [f32; 2], b: [f32; 2], c: [f32; 2]) -> f32 {
let (u, v) = ([b[0] - a[0], b[1] - a[1]], [c[0] - b[0], c[1] - b[1]]);
let (lu, lv) = (u[0].hypot(u[1]), v[0].hypot(v[1]));
if lu <= 0.0 || lv <= 0.0 {
return 0.0;
}
((u[0] * v[0] + u[1] * v[1]) / (lu * lv)).clamp(-1.0, 1.0).acos().to_degrees()
}
/// Ramer–Douglas–Peucker simplification of an open path (iterative: no deep recursion on long
/// strokes); keeps both ends.
fn rdp(p: &[[f32; 2]], eps: f32) -> Vec<[f32; 2]> {
let n = p.len();
if n < 3 {
return p.to_vec();
}
let mut keep = vec![false; n];
if let Some(f) = keep.first_mut() {
*f = true;
}
if let Some(l) = keep.last_mut() {
*l = true;
}
let mut stack = vec![(0usize, n - 1)];
while let Some((a, b)) = stack.pop() {
let (Some(&pa), Some(&pb)) = (p.get(a), p.get(b)) else { continue };
let far = (a + 1..b).filter_map(|i| Some((i, seg_distance(pa, pb, *p.get(i)?)))).fold((a, 0.0), |m, x| if x.1 > m.1 { x } else { m });
if far.1 > eps
&& let Some(k) = keep.get_mut(far.0)
{
*k = true;
stack.push((a, far.0));
stack.push((far.0, b));
}
}
p.iter().zip(keep).filter(|(_, k)| *k).map(|(q, _)| *q).collect()
}
/// A quadrilateral whose sides run (nearly) across and down the page, alternately.
fn upright(c: &[[f32; 2]]) -> bool {
let n = c.len();
let dirs: Vec<Option<bool>> = (0..n)
.map(|i| {
let (a, b) = (c.get(i)?, c.get((i + 1) % n)?);
let deg = (b[1] - a[1]).atan2(b[0] - a[0]).to_degrees().rem_euclid(180.0);
// true: across, false: down; None when it's slanted.
if deg <= 15.0 || deg >= 165.0 {
Some(true)
} else if (75.0..=105.0).contains(&deg) {
Some(false)
} else {
None
}
})
.collect();
dirs.iter().all(Option::is_some) && (0..n).all(|i| dirs.get(i) != dirs.get((i + 1) % n))
}
#[cfg(test)]
mod tests {
use super::*;
/// Points along a closed polygon, `per` to a side, with a little deterministic hand wobble.
fn around(corners: &[[f32; 2]], per: usize) -> Vec<[f32; 2]> {
let mut out = Vec::new();
for (i, a) in corners.iter().enumerate() {
let b = corners[(i + 1) % corners.len()];
for k in 0..per {
let t = k as f32 / per as f32;
let wob = (out.len() as f32 * 1.7).sin() * 0.6;
out.push([a[0] + (b[0] - a[0]) * t + wob, a[1] + (b[1] - a[1]) * t - wob]);
}
}
out.push(corners[0]);
out
}
#[test]
fn rectangles_ellipses_lines_and_triangles_are_recognised() {
// A rectangle, started mid-side as people often do.
let rect = around(&[[50.0, 10.0], [110.0, 10.0], [110.0, 70.0 + 0.0], [10.0, 70.0], [10.0, 10.0]], 12);
let Some(InkShape::Rectangle { x, y, w, h }) = recognise(&rect) else { panic!("{:?}", recognise(&rect)) };
assert!((x - 10.0).abs() < 2.0 && (y - 10.0).abs() < 2.0 && (w - 100.0).abs() < 3.0 && (h - 60.0).abs() < 3.0, "{x} {y} {w} {h}");
// Nearly square: a square.
assert_eq!(recognise(&around(&[[0.0, 0.0], [60.0, 0.0], [60.0, 57.0], [0.0, 57.0]], 10)).map(|s| s.name()), Some("square"));
// An ellipse and a circle.
let ring = |rx: f32, ry: f32| {
(0..=64).map(|i| (i as f32 / 64.0) * std::f32::consts::TAU).map(|a| [100.0 + rx * a.cos(), 80.0 + ry * a.sin()]).collect::<Vec<_>>()
};
let Some(InkShape::Ellipse { x, w, h, .. }) = recognise(&ring(40.0, 25.0)) else { panic!() };
assert!((x - 60.0).abs() < 0.5 && (w - 80.0).abs() < 0.5 && (h - 50.0).abs() < 0.5);
assert_eq!(recognise(&ring(30.0, 29.0)).map(|s| s.name()), Some("circle"));
// A slightly shaky line keeps its ends.
let line: Vec<[f32; 2]> = (0..=40).map(|i| i as f32).map(|t| [10.0 + t * 3.0, 20.0 + t + (t * 2.1).sin() * 0.8]).collect();
assert_eq!(recognise(&line), Some(InkShape::Line { a: line[0], b: line[40] }));
// A triangle and a pentagon.
let tri = around(&[[0.0, 80.0], [45.0, 0.0], [90.0, 80.0]], 15);
let Some(InkShape::Polygon(c)) = recognise(&tri) else { panic!("{:?}", recognise(&tri)) };
assert_eq!(c.len(), 3);
let penta: Vec<[f32; 2]> = (0..5)
.map(|i| (i as f32 / 5.0) * std::f32::consts::TAU - std::f32::consts::FRAC_PI_2)
.map(|a| [60.0 + 50.0 * a.cos(), 60.0 + 50.0 * a.sin()])
.collect();
assert_eq!(recognise(&around(&penta, 12)).map(|s| s.name()), Some("polygon"));
}
#[test]
fn scribbles_ticks_and_hostile_points_stay_ink() {
// An open zigzag, a hook, a tap and a tiny tick.
let zigzag: Vec<[f32; 2]> = (0..20).map(|i| [i as f32 * 8.0, if i % 2 == 0 { 0.0 } else { 30.0 }]).collect();
assert_eq!(recognise(&zigzag), None);
assert_eq!(recognise(&[[0.0, 0.0], [50.0, 0.0], [50.0, 50.0]]), None);
assert_eq!(recognise(&[[5.0, 5.0]]), None);
assert_eq!(recognise(&[[0.0, 0.0], [2.0, 3.0]]), None);
// A closed scribble with many spikes: not a polygon of eight or fewer corners, not round.
let star: Vec<[f32; 2]> = (0..=24)
.map(|i| {
let a = i as f32 / 24.0 * std::f32::consts::TAU;
let r = if i % 2 == 0 { 60.0 } else { 20.0 };
[80.0 + r * a.cos(), 80.0 + r * a.sin()]
})
.collect();
assert_eq!(recognise(&star), None);
// NaN, infinities and a flood of points never panic.
let mut junk = vec![[f32::NAN, 1.0], [f32::INFINITY, 2.0], [3.0, f32::NEG_INFINITY]];
junk.extend(std::iter::repeat_n([1.0, 1.0], MAX_POINTS * 3));
assert_eq!(recognise(&junk), None);
assert_eq!(recognise(&[]), None);
}
/// A stroke drawn as a shaft from `tail` to `tip`, then both barbs of a head (back to the
/// tip between them).
fn arrow_stroke(tail: [f32; 2], tip: [f32; 2], barb: f32, spread_deg: f32) -> Vec<[f32; 2]> {
arrow_stroke_n(tail, tip, barb, spread_deg, 24, 5)
}
/// Points from `a` towards `b` (not including `b`), `n` of them.
fn line(a: [f32; 2], b: [f32; 2], n: usize) -> Vec<[f32; 2]> {
(0..n).map(|k| k as f32 / n as f32).map(|t| [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]).collect()
}
/// An arrow sampled as a pen samples it: about one point per point of travel.
fn dense_arrow(tail: [f32; 2], tip: [f32; 2], barb: f32, spread_deg: f32) -> Vec<[f32; 2]> {
arrow_stroke_n(tail, tip, barb, spread_deg, dist(tail, tip).round() as usize, (barb.round() as usize).max(2))
}
/// `arrow_stroke` with `n` shaft points and `m` points per barb pass.
fn arrow_stroke_n(tail: [f32; 2], tip: [f32; 2], barb: f32, spread_deg: f32, n: usize, m: usize) -> Vec<[f32; 2]> {
let back = (tail[1] - tip[1]).atan2(tail[0] - tip[0]);
let barb_end = |sign: f32| {
let a = back + sign * spread_deg.to_radians();
[tip[0] + barb * a.cos(), tip[1] + barb * a.sin()]
};
let mut pts = line(tail, tip, n);
pts.extend(line(tip, barb_end(1.0), m));
pts.extend(line(barb_end(1.0), tip, m));
pts.extend(line(tip, barb_end(-1.0), m));
pts.push(barb_end(-1.0));
pts
}
#[test]
fn arrows_drawn_in_one_stroke_are_recognised_in_any_direction() {
for (tail, tip) in [
([20.0, 100.0], [180.0, 100.0]),
([180.0, 100.0], [20.0, 100.0]),
([100.0, 20.0], [100.0, 180.0]),
([100.0, 180.0], [100.0, 20.0]),
([30.0, 30.0], [150.0, 120.0]),
([150.0, 120.0], [30.0, 30.0]),
] {
let stroke = arrow_stroke(tail, tip, 25.0, 30.0);
let Some(InkShape::Arrow { a, b, head_at_end, head_at_start }) = recognise(&stroke) else {
panic!("{tail:?}->{tip:?}: {:?}", recognise(&stroke))
};
assert!(head_at_end && !head_at_start);
assert!(dist(a, tail) < 3.0 && dist(b, tip) < 3.0, "{a:?} {b:?} for {tail:?} {tip:?}");
assert_eq!(recognise(&stroke).map(|s| s.name()), Some("arrow"));
}
// Head first, then the shaft: the same arrow.
let mut reversed = arrow_stroke([20.0, 100.0], [180.0, 100.0], 25.0, 30.0);
reversed.reverse();
let Some(InkShape::Arrow { a, b, .. }) = recognise(&reversed) else { panic!("{:?}", recognise(&reversed)) };
assert!(dist(a, [20.0, 100.0]) < 3.0 && dist(b, [180.0, 100.0]) < 3.0, "{a:?} {b:?}");
}
#[test]
fn lines_vs_barbs_hooks_and_scribbles_are_not_arrows() {
let stroke = arrow_stroke([20.0, 100.0], [180.0, 100.0], 25.0, 30.0);
// A plain line stays a line.
let line: Vec<[f32; 2]> = (0..=30).map(|i| [20.0 + i as f32 * 5.0, 100.0]).collect();
assert_eq!(recognise(&line).map(|s| s.name()), Some("line"));
// A "V" alone is not an arrow (nor anything).
let v = [[100.0, 100.0], [75.0, 87.5], [100.0, 100.0], [75.0, 112.5]];
assert_eq!(recognise(&v), None);
assert_eq!(recognise(&[[75.0, 87.5], [100.0, 100.0], [75.0, 112.5]]), None);
// A line with one barb (a hook) is not an arrow.
let hook = &stroke[..34];
assert!(!matches!(recognise(hook), Some(InkShape::Arrow { .. })), "{:?}", recognise(hook));
// Barbs outside 15..75 degrees, or too long or too short, are not heads.
for (barb, spread) in [(25.0, 5.0), (25.0, 88.0), (100.0, 30.0), (1.0, 30.0)] {
let s = arrow_stroke([20.0, 100.0], [180.0, 100.0], barb, spread);
assert!(!matches!(recognise(&s), Some(InkShape::Arrow { .. })), "barb {barb} spread {spread}");
}
// Both barbs on the same side are not a head.
let lopsided = [[20.0, 100.0], [180.0, 100.0], [155.0, 87.0], [180.0, 100.0], [158.0, 80.0]];
assert!(!matches!(recognise(&lopsided), Some(InkShape::Arrow { .. })));
// A zigzag scribble stays ink.
let zig: Vec<[f32; 2]> = (0..9).map(|i| [i as f32 * 20.0, if i % 2 == 0 { 0.0 } else { 40.0 }]).collect();
assert_eq!(recognise(&zig), None);
}
#[test]
fn hostile_arrow_points_never_panic() {
let mut junk = arrow_stroke([20.0, 100.0], [180.0, 100.0], 25.0, 30.0);
junk.insert(3, [f32::NAN, 5.0]);
junk.push([f32::INFINITY, 1.0]);
assert!(matches!(recognise(&junk), Some(InkShape::Arrow { .. })));
let huge: Vec<[f32; 2]> = (0..MAX_POINTS * 2).map(|i| [(i % 977) as f32, ((i * 31) % 541) as f32]).collect();
let _ = recognise(&huge);
let _ = recognise(&[[1e5, 1e5], [-1e5, 1e5], [1e5, -1e5], [-1e5, -1e5], [0.0, 0.0]]);
let _ = recognise(&[[0.0, 0.0]; 7]);
}
/// An arrow as a hand draws it: the shaft sags by `bow` and wobbles, the tip may be rounded,
/// and each barb has its own length and angle (`(length, degrees)`, left then right).
fn hand_arrow(tail: [f32; 2], tip: [f32; 2], bow: f32, round_tip: bool, barbs: [(f32, f32); 2]) -> Vec<[f32; 2]> {
hand_arrow_n(tail, tip, bow, round_tip, barbs, 30, 6)
}
/// `hand_arrow` with `n` shaft points and `m` points per barb pass.
fn hand_arrow_n(tail: [f32; 2], tip: [f32; 2], bow: f32, round_tip: bool, barbs: [(f32, f32); 2], n: usize, m: usize) -> Vec<[f32; 2]> {
let len = dist(tail, tip);
let dir = [(tip[0] - tail[0]) / len, (tip[1] - tail[1]) / len];
let normal = [-dir[1], dir[0]];
let mut pts: Vec<[f32; 2]> = (0..n)
.map(|i| {
let t = i as f32 / n as f32;
let off = bow * 4.0 * t * (1.0 - t) + (i as f32 * 1.7).sin() * 0.6;
[tail[0] + (tip[0] - tail[0]) * t + normal[0] * off, tail[1] + (tip[1] - tail[1]) * t + normal[1] * off]
})
.collect();
let back = (tail[1] - tip[1]).atan2(tail[0] - tip[0]);
let end = |(l, deg): (f32, f32), sign: f32| {
let a = back + sign * deg.to_radians();
[tip[0] + l * a.cos(), tip[1] + l * a.sin()]
};
let seg = |a: [f32; 2], b: [f32; 2], n: usize| -> Vec<[f32; 2]> {
(0..n).map(|k| k as f32 / n as f32).map(|t| [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]).collect()
};
let (e1, e2) = (end(barbs[0], 1.0), end(barbs[1], -1.0));
if round_tip {
pts.extend([[tip[0] + dir[0] * 1.0 + normal[0] * 1.5, tip[1] + dir[1] * 1.0 + normal[1] * 1.5]]);
} else {
pts.push(tip);
}
pts.extend(seg(tip, e1, m));
pts.extend(seg(e1, tip, m));
if round_tip {
pts.push([tip[0] + dir[0] * 1.0 - normal[0] * 1.5, tip[1] + dir[1] * 1.0 - normal[1] * 1.5]);
}
pts.extend(seg(tip, e2, m));
pts.push(e2);
pts
}
#[test]
fn hand_drawn_arrows_with_bowed_wobbly_shafts_rounded_tips_and_uneven_barbs_are_arrows() {
let (tail, tip) = ([20.0, 100.0], [180.0, 100.0]);
let sym = [(25.0, 30.0), (25.0, 30.0)];
let is_arrow = |pts: &[[f32; 2]]| matches!(recognise(pts), Some(InkShape::Arrow { .. }));
// Wobble alone (about 0.6 pt), a 4 pt sag over 160 pt, and both bowed the other way.
assert!(is_arrow(&hand_arrow(tail, tip, 0.0, false, sym)), "wobble");
assert!(is_arrow(&hand_arrow(tail, tip, 4.0, false, sym)), "bowed");
assert!(is_arrow(&hand_arrow(tail, tip, -4.0, false, sym)), "bowed the other way");
// A rounded tip, and slightly asymmetric barbs (20 at 25 degrees, 30 at 40).
assert!(is_arrow(&hand_arrow(tail, tip, 0.0, true, sym)), "rounded tip");
assert!(is_arrow(&hand_arrow(tail, tip, 2.0, false, [(20.0, 25.0), (30.0, 40.0)])), "asymmetric");
// All at once, going up and to the left, and head first.
let all = hand_arrow([180.0, 160.0], [40.0, 30.0], 4.0, true, [(20.0, 25.0), (30.0, 40.0)]);
let Some(InkShape::Arrow { a, b, .. }) = recognise(&all) else { panic!("{:?}", recognise(&all)) };
assert!(dist(a, [180.0, 160.0]) < 3.0 && dist(b, [40.0, 30.0]) < 3.0, "{a:?} {b:?}");
let mut rev = all;
rev.reverse();
assert!(is_arrow(&rev), "head first");
}
#[test]
fn the_barb_limits_are_the_ones_that_are_detected() {
let (tail, tip) = ([20.0, 100.0], [180.0, 100.0]); // a 160 pt shaft
let is_arrow = |l: f32, deg: f32| matches!(recognise(&arrow_stroke(tail, tip, l, deg)), Some(InkShape::Arrow { .. }));
// Length 5 to 50 % of the shaft (8 to 80 pt), angle 15 to 75 degrees.
assert!(is_arrow(8.5, 30.0) && is_arrow(79.0, 30.0));
assert!(!is_arrow(6.0, 30.0) && !is_arrow(88.0, 30.0));
assert!(is_arrow(25.0, 17.0) && is_arrow(25.0, 73.0));
assert!(!is_arrow(25.0, 11.0) && !is_arrow(25.0, 80.0));
}
#[test]
fn letters_and_hooks_are_not_arrows() {
// A "Y" in both orders: arm to the fork, down the stem; stem up, then the other arm.
let y_down = [[40.0, 0.0], [60.0, 30.0], [80.0, 0.0], [60.0, 30.0], [60.0, 90.0]];
assert_eq!(recognise(&y_down), None);
let y_up = [[60.0, 90.0], [60.0, 30.0], [40.0, 0.0], [60.0, 30.0], [80.0, 0.0]];
assert_eq!(recognise(&y_up), None);
// A print "k": stem down, up to the middle, out to the upper right, back, out to the lower right.
let k = [[0.0, 0.0], [0.0, 100.0], [0.0, 60.0], [40.0, 30.0], [0.0, 60.0], [40.0, 100.0]];
assert_eq!(recognise(&k), None);
// A "k" drawn top, middle, upper right, middle, lower right.
let k2 = [[0.0, 0.0], [0.0, 60.0], [40.0, 30.0], [0.0, 60.0], [40.0, 100.0]];
assert_eq!(recognise(&k2), None);
// A checkmark, an "L", a "V" and a line with one barb.
assert_eq!(recognise(&[[0.0, 40.0], [20.0, 70.0], [90.0, 0.0]]), None);
assert_eq!(recognise(&[[0.0, 0.0], [0.0, 80.0], [50.0, 80.0]]), None);
let hook = &arrow_stroke([20.0, 100.0], [180.0, 100.0], 25.0, 30.0)[..34];
assert_eq!(recognise(hook), None);
let zig: Vec<[f32; 2]> = (0..9).map(|i| [i as f32 * 20.0, if i % 2 == 0 { 0.0 } else { 40.0 }]).collect();
assert_eq!(recognise(&zig), None);
}
#[test]
fn a_return_that_overshoots_the_tip_still_makes_an_arrow() {
let (tail, tip) = ([20.0, 100.0], [180.0, 100.0]);
// Shaft to the tip, first barb, back past the tip by 2 pt, then the second barb.
let barb = |sign: f32| {
let a = std::f32::consts::PI + sign * 30.0f32.to_radians();
[tip[0] + 25.0 * a.cos(), tip[1] + 25.0 * a.sin()]
};
let over = [tip[0] + 2.0, tip[1]];
let line = |a: [f32; 2], b: [f32; 2], n: usize| -> Vec<[f32; 2]> {
(0..n).map(|k| k as f32 / n as f32).map(|t| [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]).collect()
};
let mut stroke = line(tail, tip, 24);
stroke.extend(line(tip, barb(1.0), 5));
stroke.extend(line(barb(1.0), over, 5));
stroke.extend(line(over, barb(-1.0), 5));
stroke.push(barb(-1.0));
let Some(InkShape::Arrow { a, b, .. }) = recognise(&stroke) else { panic!("{:?}", recognise(&stroke)) };
assert!(dist(a, tail) < 3.0 && dist(b, tip) < 3.0, "{a:?} {b:?}");
// Head first: the same stroke backwards.
stroke.reverse();
let Some(InkShape::Arrow { a, b, .. }) = recognise(&stroke) else { panic!("{:?}", recognise(&stroke)) };
assert!(dist(a, tail) < 3.0 && dist(b, tip) < 3.0, "{a:?} {b:?}");
}
#[test]
fn densely_sampled_arrows_keep_their_tip_and_barb_limits() {
// A pen samples about every point; the tip must still be where the head was drawn.
for (tail, tip) in [([20.0, 100.0], [180.0, 100.0]), ([100.0, 180.0], [100.0, 20.0]), ([150.0, 120.0], [30.0, 30.0])] {
let stroke = dense_arrow(tail, tip, 25.0, 30.0);
let Some(InkShape::Arrow { a, b, .. }) = recognise(&stroke) else { panic!("{tail:?}->{tip:?}: {:?}", recognise(&stroke)) };
assert!(dist(a, tail) < 1.0 && dist(b, tip) < 1.0, "{a:?} {b:?} for {tail:?} {tip:?}");
let mut rev = stroke;
rev.reverse();
let Some(InkShape::Arrow { a, b, .. }) = recognise(&rev) else { panic!("head first {tail:?}->{tip:?}: {:?}", recognise(&rev)) };
assert!(dist(a, tail) < 1.0 && dist(b, tip) < 1.0, "head first {a:?} {b:?}");
}
// The documented limits hold on dense strokes too: 5 to 50 % of a 160 pt shaft, 15 to 75 degrees.
let (tail, tip) = ([20.0, 100.0], [180.0, 100.0]);
let is_arrow = |l: f32, deg: f32| matches!(recognise(&dense_arrow(tail, tip, l, deg)), Some(InkShape::Arrow { .. }));
assert!(is_arrow(8.5, 30.0) && is_arrow(8.5, 45.0) && is_arrow(8.5, 17.0) && is_arrow(79.0, 30.0), "lengths");
assert!(is_arrow(12.0, 30.0) && is_arrow(12.0, 45.0), "small barbs");
assert!(is_arrow(25.0, 17.0) && is_arrow(25.0, 70.0) && is_arrow(25.0, 73.0), "angles");
assert!(!is_arrow(6.0, 30.0) && !is_arrow(88.0, 30.0));
assert!(!is_arrow(25.0, 11.0) && !is_arrow(25.0, 80.0));
// A dense line with one barb (a hook) is still not an arrow.
let hook = dense_arrow(tail, tip, 25.0, 30.0);
assert!(!matches!(recognise(&hook[..186]), Some(InkShape::Arrow { .. })), "{:?}", recognise(&hook[..186]));
}
#[test]
fn densely_sampled_hand_drawn_arrows_and_overshoots_are_arrows() {
let (tail, tip) = ([20.0, 100.0], [180.0, 100.0]);
let sym = [(25.0, 30.0), (25.0, 30.0)];
let dense = |bow: f32, round: bool, barbs: [(f32, f32); 2]| hand_arrow_n(tail, tip, bow, round, barbs, 160, 25);
let tip_of = |pts: &[[f32; 2]]| match recognise(pts) {
Some(InkShape::Arrow { b, .. }) => Some(b),
_ => None,
};
for (what, pts) in [
("wobble", dense(0.0, false, sym)),
("bowed", dense(4.0, false, sym)),
("bowed the other way", dense(-4.0, false, sym)),
("rounded tip", dense(0.0, true, sym)),
("asymmetric", dense(2.0, false, [(20.0, 25.0), (30.0, 40.0)])),
("small", dense(0.0, false, [(10.0, 45.0), (10.0, 45.0)])),
("wide", dense(0.0, false, [(25.0, 70.0), (25.0, 70.0)])),
] {
let b = tip_of(&pts);
assert!(b.is_some_and(|b| dist(b, tip) < 2.0), "{what}: {b:?}");
let mut rev = pts;
rev.reverse();
assert!(tip_of(&rev).is_some(), "{what} head first");
}
// Back past the tip by 2 pt between the barbs, densely sampled, both ways.
let barb = |sign: f32| {
let a = std::f32::consts::PI + sign * 30.0f32.to_radians();
[tip[0] + 25.0 * a.cos(), tip[1] + 25.0 * a.sin()]
};
let over = [tip[0] + 2.0, tip[1]];
let mut stroke = line(tail, tip, 160);
stroke.extend(line(tip, barb(1.0), 25));
stroke.extend(line(barb(1.0), over, 25));
stroke.extend(line(over, barb(-1.0), 25));
stroke.push(barb(-1.0));
let b = tip_of(&stroke);
assert!(b.is_some_and(|b| dist(b, tip) < 1.0), "overshoot: {b:?}");
stroke.reverse();
let b = tip_of(&stroke);
assert!(b.is_some_and(|b| dist(b, tip) < 1.0), "overshoot head first: {b:?}");
}
/// A shaft from `tail` to `tip`, then a closed triangle head drawn round the tip in the same
/// stroke (tip, one barb end, the other, back to the tip); `n` shaft points, `m` per side.
fn closed_arrow_n(tail: [f32; 2], tip: [f32; 2], barb: f32, spread_deg: f32, n: usize, m: usize) -> Vec<[f32; 2]> {
let [b1, b2] = barb_ends(tail, tip, barb, spread_deg);
let mut pts = line(tail, tip, n);
pts.extend(line(tip, b1, m));
pts.extend(line(b1, b2, m));
pts.extend(line(b2, tip, m));
pts.push(tip);
pts
}
/// The ends of a head's two barbs at `tip`, `barb` long, `spread_deg` either side of the shaft.
fn barb_ends(tail: [f32; 2], tip: [f32; 2], barb: f32, spread_deg: f32) -> [[f32; 2]; 2] {
let back = (tail[1] - tip[1]).atan2(tail[0] - tip[0]);
let end = |sign: f32| {
let a = back + sign * spread_deg.to_radians();
[tip[0] + barb * a.cos(), tip[1] + barb * a.sin()]
};
[end(1.0), end(-1.0)]
}
#[test]
fn arrows_with_a_closed_triangle_head_in_one_stroke_are_recognised_both_ways() {
for (tail, tip) in
[([20.0, 100.0], [180.0, 100.0]), ([180.0, 100.0], [20.0, 100.0]), ([100.0, 180.0], [100.0, 20.0]), ([150.0, 120.0], [30.0, 30.0])]
{
for stroke in [closed_arrow_n(tail, tip, 25.0, 30.0, 24, 5), closed_arrow_n(tail, tip, 20.0, 25.0, dist(tail, tip).round() as usize, 15)]
{
let Some(InkShape::Arrow { a, b, head_at_end, head_at_start }) = recognise(&stroke) else {
panic!("{tail:?}->{tip:?}: {:?}", recognise(&stroke))
};
assert!(head_at_end && !head_at_start);
assert!(dist(a, tail) < 2.0 && dist(b, tip) < 2.0, "{a:?} {b:?} for {tail:?} {tip:?}");
// The loop round the head first, then the shaft: the same arrow.
let mut rev = stroke;
rev.reverse();
let Some(InkShape::Arrow { a, b, .. }) = recognise(&rev) else { panic!("head first {tail:?}->{tip:?}: {:?}", recognise(&rev)) };
assert!(dist(a, tail) < 2.0 && dist(b, tip) < 2.0, "head first {a:?} {b:?}");
}
}
// The head size limits hold: 5 to 50 % of a 160 pt shaft, 15 to 75 degrees.
let (tail, tip) = ([20.0, 100.0], [180.0, 100.0]);
let is_arrow = |l: f32, deg: f32| matches!(recognise(&closed_arrow_n(tail, tip, l, deg, 24, 5)), Some(InkShape::Arrow { .. }));
assert!(is_arrow(12.0, 30.0) && is_arrow(70.0, 30.0) && is_arrow(25.0, 20.0) && is_arrow(25.0, 70.0));
assert!(!is_arrow(6.0, 30.0) && !is_arrow(90.0, 30.0) && !is_arrow(25.0, 8.0) && !is_arrow(25.0, 85.0));
}
#[test]
fn triangles_without_a_shaft_stay_triangles() {
// Closed, and closed with the pen running on past the start along the first side.
let tri = around(&[[0.0, 80.0], [45.0, 0.0], [90.0, 80.0]], 15);
assert_eq!(recognise(&tri).map(|s| s.name()), Some("triangle"));
let mut over = tri.clone();
over.extend(line([0.0, 80.0], [45.0, 0.0], 15).into_iter().skip(1).take(8));
assert!(!matches!(recognise(&over), Some(InkShape::Arrow { .. })), "{:?}", recognise(&over));
// A long thin triangle is not a shaft with a head.
let thin = around(&[[0.0, 0.0], [160.0, 10.0], [0.0, 20.0]], 20);
assert!(!matches!(recognise(&thin), Some(InkShape::Arrow { .. })), "{:?}", recognise(&thin));
// A shaft with a triangle as big as the shaft is not an arrow.
let (tail, tip) = ([20.0, 100.0], [180.0, 100.0]);
assert!(!matches!(recognise(&closed_arrow_n(tail, tip, 150.0, 30.0, 24, 5)), Some(InkShape::Arrow { .. })));
// Hostile points around a closed head never panic.
let mut junk = closed_arrow_n(tail, tip, 25.0, 30.0, 24, 5);
junk.insert(30, [f32::NAN, f32::INFINITY]);
assert!(matches!(recognise(&junk), Some(InkShape::Arrow { .. })));
}
/// A head drawn on its own: an open V (one barb end, the apex, the other end) or a closed
/// triangle (apex, barb end, barb end, apex), `m` points per side.
fn v_head(apex: [f32; 2], [b1, b2]: [[f32; 2]; 2], m: usize) -> Vec<[f32; 2]> {
let mut pts = line(b1, apex, m);
pts.extend(line(apex, b2, m));
pts.push(b2);
pts
}
fn triangle_head(apex: [f32; 2], [b1, b2]: [[f32; 2]; 2], m: usize) -> Vec<[f32; 2]> {
let mut pts = line(apex, b1, m);
pts.extend(line(b1, b2, m));
pts.extend(line(b2, apex, m));
pts.push(apex);
pts
}
#[test]
fn a_head_stroke_at_a_line_end_pairs_into_an_arrow() {
for (tail, tip) in [([20.0, 100.0], [180.0, 100.0]), ([180.0, 160.0], [40.0, 30.0]), ([100.0, 20.0], [100.0, 180.0])] {
let shaft = line(tail, tip, 40).into_iter().chain([tip]).collect::<Vec<_>>();
let ends = barb_ends(tail, tip, 25.0, 30.0);
for (what, head) in [("V", v_head(tip, ends, 8)), ("triangle", triangle_head(tip, ends, 8)), ("bare V", vec![ends[0], tip, ends[1]])] {
let Some(InkShape::Arrow { a, b, head_at_end, head_at_start }) = pair_arrow(&shaft, &head) else {
panic!("{what} {tail:?}->{tip:?}: {:?}", pair_arrow(&shaft, &head))
};
assert!(head_at_end && !head_at_start);
assert!(dist(a, tail) < 1.0 && dist(b, tip) < 1.0, "{what}: {a:?} {b:?}");
// Drawn the other way round, the head is still found at its end.
let mut rev = head.clone();
rev.reverse();
assert!(matches!(pair_arrow(&shaft, &rev), Some(InkShape::Arrow { b, .. }) if dist(b, tip) < 1.0), "{what} reversed");
let mut back = shaft.clone();
back.reverse();
assert!(matches!(pair_arrow(&back, &head), Some(InkShape::Arrow { b, .. }) if dist(b, tip) < 1.0), "{what}, shaft drawn tip first");
}
// A triangle given as its corners (an Ink to Shape triangle), apex anywhere in the ring.
let [b1, b2] = barb_ends(tail, tip, 25.0, 30.0);
assert!(matches!(pair_arrow(&shaft, &[b1, b2, tip, b1]), Some(InkShape::Arrow { b, .. }) if dist(b, tip) < 1.0), "corners");
// An apex a few points short of the end still pairs.
let d = [(tip[0] - tail[0]) / dist(tail, tip), (tip[1] - tail[1]) / dist(tail, tip)];
let near = [tip[0] - 6.0 * d[0] + 3.0 * d[1], tip[1] - 6.0 * d[1] - 3.0 * d[0]];
assert!(pair_arrow(&shaft, &v_head(near, barb_ends(tail, near, 25.0, 30.0), 8)).is_some(), "near the end");
}
}
#[test]
fn heads_away_from_the_end_the_wrong_way_or_the_wrong_size_dont_pair() {
let (tail, tip) = ([20.0, 100.0], [180.0, 100.0]);
let shaft = line(tail, tip, 40).into_iter().chain([tip]).collect::<Vec<_>>();
let head_at = |apex: [f32; 2], barb: f32, deg: f32| v_head(apex, barb_ends(tail, apex, barb, deg), 8);
// Far from either end: mid-shaft, and 40 pt past the tip.
assert_eq!(pair_arrow(&shaft, &head_at([100.0, 100.0], 25.0, 30.0)), None);
assert_eq!(pair_arrow(&shaft, &head_at([220.0, 100.0], 25.0, 30.0)), None);
assert_eq!(pair_arrow(&shaft, &head_at([180.0, 140.0], 25.0, 30.0)), None);
// Pointing back at the shaft (barbs forward), too big, too small, too narrow, too wide.
let forward = v_head(tip, barb_ends([340.0, 100.0], tip, 25.0, 30.0), 8);
assert_eq!(pair_arrow(&shaft, &forward), None);
for (barb, deg) in [(100.0, 30.0), (5.0, 30.0), (25.0, 5.0), (25.0, 85.0)] {
assert_eq!(pair_arrow(&shaft, &head_at(tip, barb, deg)), None, "barb {barb} at {deg} degrees");
}
// Lopsided: both barbs on one side.
assert_eq!(pair_arrow(&shaft, &[[155.0, 87.0], tip, [158.0, 80.0]]), None);
// The "line" must be a line, the head a head.
let ends = barb_ends(tail, tip, 25.0, 30.0);
let zig: Vec<[f32; 2]> = (0..9).map(|i| [20.0 + i as f32 * 20.0, if i % 2 == 0 { 100.0 } else { 120.0 }]).collect();
assert_eq!(pair_arrow(&zig, &v_head(tip, ends, 8)), None);
assert_eq!(pair_arrow(&shaft, &[[150.0, 80.0], [170.0, 120.0], [175.0, 80.0], [180.0, 120.0]]), None, "a scribble");
assert_eq!(pair_arrow(&shaft, &around(&[[150.0, 60.0], [190.0, 60.0], [190.0, 140.0], [150.0, 140.0]], 8)), None, "a box");
// Hostile input never panics.
let mut junk = v_head(tip, ends, 8);
junk.insert(2, [f32::NAN, 1.0]);
junk.push([f32::INFINITY, 0.0]);
assert!(pair_arrow(&shaft, &junk).is_some());
assert_eq!(pair_arrow(&[], &[]), None);
assert_eq!(pair_arrow(&shaft, &[[180.0, 100.0]]), None);
let huge: Vec<[f32; 2]> = (0..MAX_POINTS * 2).map(|i| [(i % 977) as f32, ((i * 31) % 541) as f32]).collect();
let _ = pair_arrow(&huge, &huge);
let _ = pair_arrow(&shaft, &huge);
}
}