use serde_json::{Map, Value};
use super::doc::{SketchDoc, SketchGeometry, SketchPoint};
const CLOSED_GAP_FRAC: f64 = 0.15;
const LINE_DEV_FRAC: f64 = 0.02;
const CIRCLE_RESIDUAL_FRAC: f64 = 0.08;
const MIN_RADIUS_FRAC: f64 = 0.02;
#[derive(Clone, Debug, PartialEq)]
pub enum HandDrawShape {
Line { a: (f64, f64), b: (f64, f64) },
Circle { center: (f64, f64), rim: (f64, f64) },
Arc {
center: (f64, f64),
start: (f64, f64),
end: (f64, f64),
},
Bezier { controls: [(f64, f64); 4] },
}
impl HandDrawShape {
pub fn kind(&self) -> &'static str {
match self {
HandDrawShape::Line { .. } => "line",
HandDrawShape::Circle { .. } => "circle",
HandDrawShape::Arc { .. } => "arc",
HandDrawShape::Bezier { .. } => "bezier",
}
}
}
pub fn recognize(stroke: &[(f64, f64)]) -> HandDrawShape {
let n = stroke.len();
if n < 2 {
let p = stroke.first().copied().unwrap_or((0.0, 0.0));
return HandDrawShape::Line { a: p, b: p };
}
let a = stroke[0];
let b = stroke[n - 1];
let extent = stroke_extent(stroke).max(1e-9);
let closed = dist(a, b) <= CLOSED_GAP_FRAC * extent;
if !closed {
let max_dev = stroke[1..n - 1]
.iter()
.map(|&p| point_segment_distance(p, a, b))
.fold(0.0_f64, f64::max);
if n == 2 || max_dev <= LINE_DEV_FRAC * extent {
return HandDrawShape::Line { a, b };
}
}
if n >= 3 {
if let Some((cx, cy, r)) = fit_circle_lsq(stroke) {
let residual = stroke
.iter()
.map(|&p| (dist(p, (cx, cy)) - r).abs())
.fold(0.0_f64, f64::max);
if r.is_finite()
&& r > MIN_RADIUS_FRAC * extent
&& residual <= CIRCLE_RESIDUAL_FRAC * extent
{
if closed {
return HandDrawShape::Circle {
center: (cx, cy),
rim: (cx + r, cy),
};
}
return HandDrawShape::Arc {
center: (cx, cy),
start: a,
end: b,
};
}
}
}
HandDrawShape::Bezier {
controls: fit_cubic(stroke),
}
}
pub fn emit_shape(doc: &mut SketchDoc, shape: &HandDrawShape, snap_radius: f64) {
let base = doc.points.len();
match shape {
HandDrawShape::Line { a, b } => {
let a_id = snap_new_point(doc, base, a.0, a.1, snap_radius);
let b_id = snap_new_point(doc, base, b.0, b.1, snap_radius);
push_geometry(doc, "line", vec![a_id, b_id], false);
}
HandDrawShape::Circle { center, rim } => {
let c = snap_new_point(doc, base, center.0, center.1, snap_radius);
let r = snap_new_point(doc, base, rim.0, rim.1, snap_radius);
push_geometry(doc, "circle", vec![c, r], false);
}
HandDrawShape::Arc { center, start, end } => {
let c = snap_new_point(doc, base, center.0, center.1, snap_radius);
let s = snap_new_point(doc, base, start.0, start.1, snap_radius);
let e = snap_new_point(doc, base, end.0, end.1, snap_radius);
push_geometry(doc, "arc", vec![c, s, e], false);
}
HandDrawShape::Bezier { controls } => {
let ids: Vec<Value> = controls
.iter()
.map(|&(u, v)| snap_new_point(doc, base, u, v, snap_radius))
.collect();
push_geometry(doc, "bezier", ids.clone(), false);
push_geometry(doc, "line", vec![ids[0].clone(), ids[1].clone()], true);
push_geometry(doc, "line", vec![ids[3].clone(), ids[2].clone()], true);
}
}
}
pub fn stroke_extent(stroke: &[(f64, f64)]) -> f64 {
let (mut minx, mut miny) = (f64::INFINITY, f64::INFINITY);
let (mut maxx, mut maxy) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
for &(x, y) in stroke {
minx = minx.min(x);
miny = miny.min(y);
maxx = maxx.max(x);
maxy = maxy.max(y);
}
if !minx.is_finite() {
return 0.0;
}
((maxx - minx).powi(2) + (maxy - miny).powi(2)).sqrt()
}
fn fit_cubic(stroke: &[(f64, f64)]) -> [(f64, f64); 4] {
let n = stroke.len();
let first = stroke[0];
let last = stroke[n - 1];
let mut cum = vec![0.0_f64; n];
for i in 1..n {
cum[i] = cum[i - 1] + dist(stroke[i - 1], stroke[i]);
}
let total = cum[n - 1];
if total < 1e-9 {
return [first, first, last, last];
}
let c1 = sample_arc(stroke, &cum, total, 1.0 / 3.0);
let c2 = sample_arc(stroke, &cum, total, 2.0 / 3.0);
[first, c1, c2, last]
}
fn sample_arc(stroke: &[(f64, f64)], cum: &[f64], total: f64, t: f64) -> (f64, f64) {
let target = total * t;
let mut idx = 0;
while idx < cum.len() && cum[idx] < target {
idx += 1;
}
if idx == 0 {
return stroke[0];
}
if idx >= cum.len() {
return stroke[stroke.len() - 1];
}
let (d0, d1) = (cum[idx - 1], cum[idx]);
let span = (d1 - d0).max(1e-9);
let tt = ((target - d0) / span).clamp(0.0, 1.0);
let p0 = stroke[idx - 1];
let p1 = stroke[idx];
(p0.0 + (p1.0 - p0.0) * tt, p0.1 + (p1.1 - p0.1) * tt)
}
fn fit_circle_lsq(pts: &[(f64, f64)]) -> Option<(f64, f64, f64)> {
let n = pts.len();
if n < 3 {
return None;
}
let nf = n as f64;
let (mut mx, mut my) = (0.0_f64, 0.0_f64);
for &(x, y) in pts {
mx += x;
my += y;
}
mx /= nf;
my /= nf;
let (mut sxx, mut sxy, mut syy) = (0.0_f64, 0.0_f64, 0.0_f64);
let (mut sxz, mut syz) = (0.0_f64, 0.0_f64);
for &(x, y) in pts {
let u = x - mx;
let v = y - my;
let z = u * u + v * v;
sxx += u * u;
sxy += u * v;
syy += v * v;
sxz += u * z;
syz += v * z;
}
let det = sxx * syy - sxy * sxy;
if det.abs() < 1e-12 {
return None; }
let uc = (sxz * syy - syz * sxy) / (2.0 * det);
let vc = (sxx * syz - sxy * sxz) / (2.0 * det);
let cx = uc + mx;
let cy = vc + my;
let r = (uc * uc + vc * vc + (sxx + syy) / nf).sqrt();
if !cx.is_finite() || !cy.is_finite() || !r.is_finite() {
return None;
}
Some((cx, cy, r))
}
fn snap_new_point(doc: &mut SketchDoc, base: usize, u: f64, v: f64, radius: f64) -> Value {
let mut best: Option<(f64, Value)> = None;
for p in doc.points.iter().take(base) {
let d = ((p.x - u).powi(2) + (p.y - v).powi(2)).sqrt();
if d <= radius && best.as_ref().map_or(true, |(bd, _)| d < *bd) {
best = Some((d, p.id.clone()));
}
}
if let Some((_, id)) = best {
return id;
}
let id = doc.next_point_id();
doc.points.push(SketchPoint {
id: id.clone(),
x: u,
y: v,
fixed: false,
construction: false,
external_reference: false,
});
id
}
fn push_geometry(doc: &mut SketchDoc, geom_type: &str, points: Vec<Value>, construction: bool) {
let id = doc.next_geometry_id();
let mut extra = Map::new();
extra.insert("construction".to_string(), Value::Bool(construction));
doc.geometries.push(SketchGeometry {
id,
geom_type: geom_type.to_string(),
points,
extra,
});
}
fn dist(a: (f64, f64), b: (f64, f64)) -> f64 {
((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt()
}
fn point_segment_distance(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let len2 = dx * dx + dy * dy;
let t = if len2 <= 1e-18 {
0.0
} else {
(((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len2).clamp(0.0, 1.0)
};
dist(p, (a.0 + t * dx, a.1 + t * dy))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sketch::doc::id_key;
use std::f64::consts::{PI, TAU};
#[test]
fn recognize_straight_stroke_is_a_line() {
let stroke: Vec<(f64, f64)> = (0..=10).map(|i| (i as f64, 2.0 * i as f64)).collect();
match recognize(&stroke) {
HandDrawShape::Line { a, b } => {
assert_eq!(a, (0.0, 0.0));
assert_eq!(b, (10.0, 20.0));
}
other => panic!("expected line, got {other:?}"),
}
}
#[test]
fn recognize_two_samples_is_a_line() {
assert_eq!(
recognize(&[(1.0, 1.0), (5.0, 9.0)]),
HandDrawShape::Line {
a: (1.0, 1.0),
b: (5.0, 9.0)
}
);
}
#[test]
fn recognize_closed_circle() {
let (cx, cy, r) = (3.0, -2.0, 5.0);
let n = 64;
let stroke: Vec<(f64, f64)> = (0..=n)
.map(|i| {
let t = i as f64 / n as f64 * TAU;
(cx + r * t.cos(), cy + r * t.sin())
})
.collect();
match recognize(&stroke) {
HandDrawShape::Circle { center, rim } => {
assert!((center.0 - cx).abs() < 1e-6 && (center.1 - cy).abs() < 1e-6);
assert!((dist(center, rim) - r).abs() < 1e-6);
assert!((rim.1 - cy).abs() < 1e-9, "rim should sit on +u");
}
other => panic!("expected circle, got {other:?}"),
}
}
#[test]
fn recognize_wobbly_circle() {
let (cx, cy, r) = (0.0, 0.0, 10.0);
let n = 40;
let stroke: Vec<(f64, f64)> = (0..n)
.map(|i| {
let t = i as f64 / n as f64 * (TAU * 0.96);
let rr = r + 0.2 * ((i * 7 % 5) as f64 - 2.0);
(cx + rr * t.cos(), cy + rr * t.sin())
})
.collect();
assert_eq!(recognize(&stroke).kind(), "circle");
}
#[test]
fn recognize_open_arc() {
let (cx, cy, r) = (0.0, 0.0, 4.0);
let n = 16;
let stroke: Vec<(f64, f64)> = (0..=n)
.map(|i| {
let t = i as f64 / n as f64 * (PI / 2.0);
(cx + r * t.cos(), cy + r * t.sin())
})
.collect();
match recognize(&stroke) {
HandDrawShape::Arc { center, start, end } => {
assert!((center.0 - cx).abs() < 1e-6 && (center.1 - cy).abs() < 1e-6);
assert!((start.0 - r).abs() < 1e-6 && start.1.abs() < 1e-6);
assert!(end.0.abs() < 1e-6 && (end.1 - r).abs() < 1e-6);
}
other => panic!("expected arc, got {other:?}"),
}
}
#[test]
fn recognize_wiggly_is_bezier() {
let stroke: Vec<(f64, f64)> = (0..=40)
.map(|i| {
let x = i as f64 * 0.5;
(x, 3.0 * (x * 0.9).sin())
})
.collect();
match recognize(&stroke) {
HandDrawShape::Bezier { controls } => {
assert_eq!(controls[0], *stroke.first().unwrap());
assert_eq!(controls[3], *stroke.last().unwrap());
assert!(controls[1].0 > controls[0].0 && controls[2].0 > controls[1].0);
}
other => panic!("expected bezier, got {other:?}"),
}
}
#[test]
fn recognize_zigzag_is_bezier() {
let stroke: Vec<(f64, f64)> = (0..=8)
.map(|i| (i as f64, if i % 2 == 0 { 0.0 } else { 4.0 }))
.collect();
assert_eq!(recognize(&stroke).kind(), "bezier");
}
#[test]
fn emit_circle_adds_two_points_and_a_circle() {
let mut doc = SketchDoc::default();
emit_shape(
&mut doc,
&HandDrawShape::Circle {
center: (2.0, 3.0),
rim: (7.0, 3.0),
},
0.5,
);
assert_eq!(doc.points.len(), 2);
assert_eq!(doc.geometries.len(), 1);
let g = &doc.geometries[0];
assert_eq!(g.geom_type, "circle");
assert!(!g.construction());
assert_eq!(g.points.len(), 2);
}
#[test]
fn emit_line_adds_two_points_and_a_line() {
let mut doc = SketchDoc::default();
emit_shape(
&mut doc,
&HandDrawShape::Line {
a: (0.0, 0.0),
b: (10.0, 0.0),
},
0.5,
);
assert_eq!(doc.points.len(), 2);
assert_eq!(doc.geometries.len(), 1);
assert_eq!(doc.geometries[0].geom_type, "line");
}
#[test]
fn emit_bezier_adds_four_points_geometry_and_guides() {
let mut doc = SketchDoc::default();
emit_shape(
&mut doc,
&HandDrawShape::Bezier {
controls: [(0.0, 0.0), (1.0, 2.0), (3.0, 2.0), (4.0, 0.0)],
},
0.1,
);
assert_eq!(doc.points.len(), 4);
assert_eq!(doc.geometries.len(), 3);
assert_eq!(doc.geometries[0].geom_type, "bezier");
assert!(doc.geometries[1].construction() && doc.geometries[2].construction());
}
#[test]
fn emit_snaps_endpoint_onto_existing_point() {
let mut doc: SketchDoc = serde_json::from_value(serde_json::json!({
"points": [{ "id": 42, "x": 0.0, "y": 0.0 }],
"geometries": [],
"constraints": []
}))
.unwrap();
emit_shape(
&mut doc,
&HandDrawShape::Line {
a: (0.05, 0.0),
b: (10.0, 0.0),
},
0.5,
);
assert_eq!(doc.points.len(), 2);
let line = &doc.geometries[0];
assert_eq!(id_key(&line.points[0]), "42");
assert_ne!(id_key(&line.points[1]), "42");
}
#[test]
fn emit_does_not_collapse_own_points() {
let mut doc = SketchDoc::default();
emit_shape(
&mut doc,
&HandDrawShape::Bezier {
controls: [(0.0, 0.0), (0.1, 0.0), (0.2, 0.0), (0.3, 0.0)],
},
5.0,
);
assert_eq!(doc.points.len(), 4, "own control points must stay distinct");
}
#[test]
fn stroke_extent_is_the_bbox_diagonal() {
let ext = stroke_extent(&[(0.0, 0.0), (3.0, 0.0), (3.0, 4.0)]);
assert!((ext - 5.0).abs() < 1e-9);
assert_eq!(stroke_extent(&[]), 0.0);
}
}