Skip to main content

brep_render/sketch/
handdraw.rs

1//! Freehand (handdraw) recognition (S6b-3) — turn a raw hand-drawn stroke into a
2//! clean sketch primitive.
3//!
4//! The handdraw tool captures a drag as a polyline of plane `(u, v)` samples; this
5//! module classifies that stroke into ONE recognized shape and materializes it into
6//! the doc. Mirrors `#createGeometryFromHandDrawShape`
7//! (line/circle/arc) + `#createBezierFromStroke` (the cubic fallback), but runs a
8//! LIGHTWEIGHT recognizer directly on the uv stroke rather than the previous image-based
9//! vectorizer.
10//!
11//! Classification (relative tolerances against the stroke's bbox extent, so the same
12//! thresholds hold at any sketch scale):
13//! - **Line** — the stroke is OPEN and every interior sample lies within
14//!   `LINE_DEV_FRAC · extent` of the endpoint chord.
15//! - **Circle** — the stroke is nearly CLOSED (endpoint gap `< CLOSED_GAP_FRAC ·
16//!   extent`) AND a least-squares circle fit has a max radial residual under
17//!   `CIRCLE_RESIDUAL_FRAC · extent`.
18//! - **Arc** — OPEN, not straight, same good circle fit → an arc through the two
19//!   endpoints about the fitted center (the solver/profile normalize the sweep).
20//! - **Bezier** — anything else: a single cubic through the stroke (endpoints =
21//!   first/last, the two controls sampled at ~1/3 and ~2/3 arc length).
22
23use serde_json::{Map, Value};
24
25use super::doc::{SketchDoc, SketchGeometry, SketchPoint};
26
27/// Endpoint gap (as a fraction of the stroke extent) under which the stroke counts
28/// as CLOSED — a freehand loop rarely returns to its exact start.
29const CLOSED_GAP_FRAC: f64 = 0.15;
30/// Max interior perpendicular deviation from the endpoint chord (fraction of extent)
31/// for the stroke to read as a straight LINE.
32const LINE_DEV_FRAC: f64 = 0.02;
33/// Max radial residual of the circle fit (fraction of extent) for the stroke to read
34/// as a CIRCLE / ARC — loose enough to absorb hand wobble.
35const CIRCLE_RESIDUAL_FRAC: f64 = 0.08;
36/// A fitted radius below this fraction of the extent is a degenerate dot, not a
37/// circle.
38const MIN_RADIUS_FRAC: f64 = 0.02;
39
40/// A recognized handdraw shape, in plane `(u, v)` coordinates. Point semantics match
41/// the solver's geometry (`line = [a, b]`, `circle = [center, rim]`,
42/// `arc = [center, start, end]`, `bezier = [p0, c1, c2, p1]`).
43#[derive(Clone, Debug, PartialEq)]
44pub enum HandDrawShape {
45    /// A straight segment through the stroke's two endpoints.
46    Line { a: (f64, f64), b: (f64, f64) },
47    /// A closed circle — the fitted center + a rim point on `+u`.
48    Circle { center: (f64, f64), rim: (f64, f64) },
49    /// A circular arc — the fitted center + the stroke's start/end (CCW start→end).
50    Arc {
51        center: (f64, f64),
52        start: (f64, f64),
53        end: (f64, f64),
54    },
55    /// A cubic Bezier fallback: `[p0, c1, c2, p1]` (endpoints + two on-curve controls).
56    Bezier { controls: [(f64, f64); 4] },
57}
58
59impl HandDrawShape {
60    /// The solver geometry `type` this shape materializes as.
61    pub fn kind(&self) -> &'static str {
62        match self {
63            HandDrawShape::Line { .. } => "line",
64            HandDrawShape::Circle { .. } => "circle",
65            HandDrawShape::Arc { .. } => "arc",
66            HandDrawShape::Bezier { .. } => "bezier",
67        }
68    }
69}
70
71/// Classify a raw uv `stroke` into one recognized [`HandDrawShape`]. See the module
72/// docs for the tolerance rules. A stroke of fewer than 2 samples degenerates to a
73/// zero-length line (callers guard against tiny strokes before recognizing).
74pub fn recognize(stroke: &[(f64, f64)]) -> HandDrawShape {
75    let n = stroke.len();
76    if n < 2 {
77        let p = stroke.first().copied().unwrap_or((0.0, 0.0));
78        return HandDrawShape::Line { a: p, b: p };
79    }
80    let a = stroke[0];
81    let b = stroke[n - 1];
82    let extent = stroke_extent(stroke).max(1e-9);
83    let closed = dist(a, b) <= CLOSED_GAP_FRAC * extent;
84
85    // LINE — an open stroke whose interior hugs the endpoint chord (a 2-sample stroke
86    // is trivially straight).
87    if !closed {
88        let max_dev = stroke[1..n - 1]
89            .iter()
90            .map(|&p| point_segment_distance(p, a, b))
91            .fold(0.0_f64, f64::max);
92        if n == 2 || max_dev <= LINE_DEV_FRAC * extent {
93            return HandDrawShape::Line { a, b };
94        }
95    }
96
97    // CIRCLE / ARC — a good least-squares circle fit (non-degenerate radius, small
98    // radial residual). Closed → a full circle; open → an arc through the endpoints.
99    if n >= 3 {
100        if let Some((cx, cy, r)) = fit_circle_lsq(stroke) {
101            let residual = stroke
102                .iter()
103                .map(|&p| (dist(p, (cx, cy)) - r).abs())
104                .fold(0.0_f64, f64::max);
105            if r.is_finite()
106                && r > MIN_RADIUS_FRAC * extent
107                && residual <= CIRCLE_RESIDUAL_FRAC * extent
108            {
109                if closed {
110                    return HandDrawShape::Circle {
111                        center: (cx, cy),
112                        rim: (cx + r, cy),
113                    };
114                }
115                return HandDrawShape::Arc {
116                    center: (cx, cy),
117                    start: a,
118                    end: b,
119                };
120            }
121        }
122    }
123
124    // BEZIER fallback — a single cubic through the stroke.
125    HandDrawShape::Bezier {
126        controls: fit_cubic(stroke),
127    }
128}
129
130/// Materialize a recognized [`HandDrawShape`] into `doc`: mint its points (endpoints
131/// snap to an existing PRE-STROKE point within `snap_radius` so a stroke drawn onto
132/// prior geometry coincides) and append the geometry (non-construction; the bezier
133/// fallback also adds its two dashed control-handle guide lines, matching the bezier
134/// tool). Never solves; the caller re-solves.
135pub fn emit_shape(doc: &mut SketchDoc, shape: &HandDrawShape, snap_radius: f64) {
136    // Only points that existed BEFORE this emit are snap targets, so a shape's own
137    // freshly-minted points never collapse into one another (a short stroke's
138    // endpoints / a bezier's controls stay distinct).
139    let base = doc.points.len();
140    match shape {
141        HandDrawShape::Line { a, b } => {
142            let a_id = snap_new_point(doc, base, a.0, a.1, snap_radius);
143            let b_id = snap_new_point(doc, base, b.0, b.1, snap_radius);
144            push_geometry(doc, "line", vec![a_id, b_id], false);
145        }
146        HandDrawShape::Circle { center, rim } => {
147            let c = snap_new_point(doc, base, center.0, center.1, snap_radius);
148            let r = snap_new_point(doc, base, rim.0, rim.1, snap_radius);
149            push_geometry(doc, "circle", vec![c, r], false);
150        }
151        HandDrawShape::Arc { center, start, end } => {
152            let c = snap_new_point(doc, base, center.0, center.1, snap_radius);
153            let s = snap_new_point(doc, base, start.0, start.1, snap_radius);
154            let e = snap_new_point(doc, base, end.0, end.1, snap_radius);
155            push_geometry(doc, "arc", vec![c, s, e], false);
156        }
157        HandDrawShape::Bezier { controls } => {
158            let ids: Vec<Value> = controls
159                .iter()
160                .map(|&(u, v)| snap_new_point(doc, base, u, v, snap_radius))
161                .collect();
162            push_geometry(doc, "bezier", ids.clone(), false);
163            // Dashed control-handle guides (end0→ctrl0, end1→ctrl1) — matches the
164            // click-driven bezier tool.
165            push_geometry(doc, "line", vec![ids[0].clone(), ids[1].clone()], true);
166            push_geometry(doc, "line", vec![ids[3].clone(), ids[2].clone()], true);
167        }
168    }
169}
170
171/// The bounding-box diagonal of a uv stroke — the relative-tolerance scale (and the
172/// engine's "too tiny to recognize" gauge).
173pub fn stroke_extent(stroke: &[(f64, f64)]) -> f64 {
174    let (mut minx, mut miny) = (f64::INFINITY, f64::INFINITY);
175    let (mut maxx, mut maxy) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
176    for &(x, y) in stroke {
177        minx = minx.min(x);
178        miny = miny.min(y);
179        maxx = maxx.max(x);
180        maxy = maxy.max(y);
181    }
182    if !minx.is_finite() {
183        return 0.0;
184    }
185    ((maxx - minx).powi(2) + (maxy - miny).powi(2)).sqrt()
186}
187
188/// Fit a cubic through the stroke: endpoints = first/last, the two controls sampled
189/// at ~1/3 and ~2/3 of the ARC LENGTH (on-curve approximation, mirroring the previous
190/// cubic-from-stroke fit). A zero-length stroke collapses to its endpoints.
191fn fit_cubic(stroke: &[(f64, f64)]) -> [(f64, f64); 4] {
192    let n = stroke.len();
193    let first = stroke[0];
194    let last = stroke[n - 1];
195    let mut cum = vec![0.0_f64; n];
196    for i in 1..n {
197        cum[i] = cum[i - 1] + dist(stroke[i - 1], stroke[i]);
198    }
199    let total = cum[n - 1];
200    if total < 1e-9 {
201        return [first, first, last, last];
202    }
203    let c1 = sample_arc(stroke, &cum, total, 1.0 / 3.0);
204    let c2 = sample_arc(stroke, &cum, total, 2.0 / 3.0);
205    [first, c1, c2, last]
206}
207
208/// Sample the stroke at fractional arc length `t ∈ [0, 1]` (linear between the two
209/// bracketing samples).
210fn sample_arc(stroke: &[(f64, f64)], cum: &[f64], total: f64, t: f64) -> (f64, f64) {
211    let target = total * t;
212    let mut idx = 0;
213    while idx < cum.len() && cum[idx] < target {
214        idx += 1;
215    }
216    if idx == 0 {
217        return stroke[0];
218    }
219    if idx >= cum.len() {
220        return stroke[stroke.len() - 1];
221    }
222    let (d0, d1) = (cum[idx - 1], cum[idx]);
223    let span = (d1 - d0).max(1e-9);
224    let tt = ((target - d0) / span).clamp(0.0, 1.0);
225    let p0 = stroke[idx - 1];
226    let p1 = stroke[idx];
227    (p0.0 + (p1.0 - p0.0) * tt, p0.1 + (p1.1 - p0.1) * tt)
228}
229
230/// A modified (centered Kåsa) least-squares circle fit over all samples: robust to
231/// hand wobble and dense sampling. Returns `(cx, cy, r)` or `None` when the samples
232/// are (near) collinear.
233fn fit_circle_lsq(pts: &[(f64, f64)]) -> Option<(f64, f64, f64)> {
234    let n = pts.len();
235    if n < 3 {
236        return None;
237    }
238    let nf = n as f64;
239    let (mut mx, mut my) = (0.0_f64, 0.0_f64);
240    for &(x, y) in pts {
241        mx += x;
242        my += y;
243    }
244    mx /= nf;
245    my /= nf;
246    // Centered moments (subtracting the centroid conditions the normal equations).
247    let (mut sxx, mut sxy, mut syy) = (0.0_f64, 0.0_f64, 0.0_f64);
248    let (mut sxz, mut syz) = (0.0_f64, 0.0_f64);
249    for &(x, y) in pts {
250        let u = x - mx;
251        let v = y - my;
252        let z = u * u + v * v;
253        sxx += u * u;
254        sxy += u * v;
255        syy += v * v;
256        sxz += u * z;
257        syz += v * z;
258    }
259    let det = sxx * syy - sxy * sxy;
260    if det.abs() < 1e-12 {
261        return None; // collinear
262    }
263    // Solve [sxx sxy; sxy syy][uc; vc] = [sxz/2; syz/2].
264    let uc = (sxz * syy - syz * sxy) / (2.0 * det);
265    let vc = (sxx * syz - sxy * sxz) / (2.0 * det);
266    let cx = uc + mx;
267    let cy = vc + my;
268    let r = (uc * uc + vc * vc + (sxx + syy) / nf).sqrt();
269    if !cx.is_finite() || !cy.is_finite() || !r.is_finite() {
270        return None;
271    }
272    Some((cx, cy, r))
273}
274
275/// Snap `(u, v)` to the nearest point among the first `base` doc points within
276/// `radius` (reusing its id so a stroke endpoint coincides with prior geometry), else
277/// mint a fresh free point. Freshly-appended points (index `>= base`) are never snap
278/// targets, so a single shape's points stay distinct.
279fn snap_new_point(doc: &mut SketchDoc, base: usize, u: f64, v: f64, radius: f64) -> Value {
280    let mut best: Option<(f64, Value)> = None;
281    for p in doc.points.iter().take(base) {
282        let d = ((p.x - u).powi(2) + (p.y - v).powi(2)).sqrt();
283        if d <= radius && best.as_ref().map_or(true, |(bd, _)| d < *bd) {
284            best = Some((d, p.id.clone()));
285        }
286    }
287    if let Some((_, id)) = best {
288        return id;
289    }
290    let id = doc.next_point_id();
291    doc.points.push(SketchPoint {
292        id: id.clone(),
293        x: u,
294        y: v,
295        fixed: false,
296        construction: false,
297        external_reference: false,
298    });
299    id
300}
301
302/// Append a geometry with a freshly minted id and an explicit `construction` flag.
303fn push_geometry(doc: &mut SketchDoc, geom_type: &str, points: Vec<Value>, construction: bool) {
304    let id = doc.next_geometry_id();
305    let mut extra = Map::new();
306    extra.insert("construction".to_string(), Value::Bool(construction));
307    doc.geometries.push(SketchGeometry {
308        id,
309        geom_type: geom_type.to_string(),
310        points,
311        extra,
312    });
313}
314
315/// Euclidean distance between two `(u, v)` points.
316fn dist(a: (f64, f64), b: (f64, f64)) -> f64 {
317    ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt()
318}
319
320/// Distance from point `p` to segment `a`–`b` (all in `(u, v)`).
321fn point_segment_distance(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 {
322    let (dx, dy) = (b.0 - a.0, b.1 - a.1);
323    let len2 = dx * dx + dy * dy;
324    let t = if len2 <= 1e-18 {
325        0.0
326    } else {
327        (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len2).clamp(0.0, 1.0)
328    };
329    dist(p, (a.0 + t * dx, a.1 + t * dy))
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use crate::sketch::doc::id_key;
336    use std::f64::consts::{PI, TAU};
337
338    /// A straight stroke (multiple collinear samples) recognizes as a line through
339    /// its endpoints.
340    #[test]
341    fn recognize_straight_stroke_is_a_line() {
342        let stroke: Vec<(f64, f64)> = (0..=10).map(|i| (i as f64, 2.0 * i as f64)).collect();
343        match recognize(&stroke) {
344            HandDrawShape::Line { a, b } => {
345                assert_eq!(a, (0.0, 0.0));
346                assert_eq!(b, (10.0, 20.0));
347            }
348            other => panic!("expected line, got {other:?}"),
349        }
350    }
351
352    /// A two-sample stroke is trivially a line.
353    #[test]
354    fn recognize_two_samples_is_a_line() {
355        assert_eq!(
356            recognize(&[(1.0, 1.0), (5.0, 9.0)]),
357            HandDrawShape::Line {
358                a: (1.0, 1.0),
359                b: (5.0, 9.0)
360            }
361        );
362    }
363
364    /// A full closed circular stroke recognizes as a circle with the fitted center +
365    /// radius (rim on `+u`).
366    #[test]
367    fn recognize_closed_circle() {
368        let (cx, cy, r) = (3.0, -2.0, 5.0);
369        let n = 64;
370        let stroke: Vec<(f64, f64)> = (0..=n)
371            .map(|i| {
372                let t = i as f64 / n as f64 * TAU;
373                (cx + r * t.cos(), cy + r * t.sin())
374            })
375            .collect();
376        match recognize(&stroke) {
377            HandDrawShape::Circle { center, rim } => {
378                assert!((center.0 - cx).abs() < 1e-6 && (center.1 - cy).abs() < 1e-6);
379                assert!((dist(center, rim) - r).abs() < 1e-6);
380                assert!((rim.1 - cy).abs() < 1e-9, "rim should sit on +u");
381            }
382            other => panic!("expected circle, got {other:?}"),
383        }
384    }
385
386    /// A slightly wobbly, not-quite-closed circular stroke still recognizes as a
387    /// circle (loose radial tolerance + closed gap tolerance).
388    #[test]
389    fn recognize_wobbly_circle() {
390        let (cx, cy, r) = (0.0, 0.0, 10.0);
391        let n = 40;
392        // Stop just short of closing (a small gap under the closed tolerance) and add a
393        // little radial jitter.
394        let stroke: Vec<(f64, f64)> = (0..n)
395            .map(|i| {
396                let t = i as f64 / n as f64 * (TAU * 0.96);
397                let rr = r + 0.2 * ((i * 7 % 5) as f64 - 2.0);
398                (cx + rr * t.cos(), cy + rr * t.sin())
399            })
400            .collect();
401        assert_eq!(recognize(&stroke).kind(), "circle");
402    }
403
404    /// A quarter-circle (open) recognizes as an arc through its endpoints about the
405    /// fitted center.
406    #[test]
407    fn recognize_open_arc() {
408        let (cx, cy, r) = (0.0, 0.0, 4.0);
409        let n = 16;
410        let stroke: Vec<(f64, f64)> = (0..=n)
411            .map(|i| {
412                let t = i as f64 / n as f64 * (PI / 2.0);
413                (cx + r * t.cos(), cy + r * t.sin())
414            })
415            .collect();
416        match recognize(&stroke) {
417            HandDrawShape::Arc { center, start, end } => {
418                assert!((center.0 - cx).abs() < 1e-6 && (center.1 - cy).abs() < 1e-6);
419                assert!((start.0 - r).abs() < 1e-6 && start.1.abs() < 1e-6);
420                assert!(end.0.abs() < 1e-6 && (end.1 - r).abs() < 1e-6);
421            }
422            other => panic!("expected arc, got {other:?}"),
423        }
424    }
425
426    /// A wiggly (non-straight, non-circular) stroke falls back to a cubic bezier
427    /// whose endpoints are the stroke's first/last.
428    #[test]
429    fn recognize_wiggly_is_bezier() {
430        // An open sine wave: not straight, not circular.
431        let stroke: Vec<(f64, f64)> = (0..=40)
432            .map(|i| {
433                let x = i as f64 * 0.5;
434                (x, 3.0 * (x * 0.9).sin())
435            })
436            .collect();
437        match recognize(&stroke) {
438            HandDrawShape::Bezier { controls } => {
439                assert_eq!(controls[0], *stroke.first().unwrap());
440                assert_eq!(controls[3], *stroke.last().unwrap());
441                // The interior controls are sampled strictly between the endpoints.
442                assert!(controls[1].0 > controls[0].0 && controls[2].0 > controls[1].0);
443            }
444            other => panic!("expected bezier, got {other:?}"),
445        }
446    }
447
448    /// A sharp zigzag also falls back to a bezier (circle fit residual is large).
449    #[test]
450    fn recognize_zigzag_is_bezier() {
451        let stroke: Vec<(f64, f64)> = (0..=8)
452            .map(|i| (i as f64, if i % 2 == 0 { 0.0 } else { 4.0 }))
453            .collect();
454        assert_eq!(recognize(&stroke).kind(), "bezier");
455    }
456
457    /// `emit_shape` for a circle adds exactly 2 points (center + rim) and 1 circle
458    /// geometry (non-construction).
459    #[test]
460    fn emit_circle_adds_two_points_and_a_circle() {
461        let mut doc = SketchDoc::default();
462        emit_shape(
463            &mut doc,
464            &HandDrawShape::Circle {
465                center: (2.0, 3.0),
466                rim: (7.0, 3.0),
467            },
468            0.5,
469        );
470        assert_eq!(doc.points.len(), 2);
471        assert_eq!(doc.geometries.len(), 1);
472        let g = &doc.geometries[0];
473        assert_eq!(g.geom_type, "circle");
474        assert!(!g.construction());
475        assert_eq!(g.points.len(), 2);
476    }
477
478    /// `emit_shape` for a line adds 2 points and 1 line geometry.
479    #[test]
480    fn emit_line_adds_two_points_and_a_line() {
481        let mut doc = SketchDoc::default();
482        emit_shape(
483            &mut doc,
484            &HandDrawShape::Line {
485                a: (0.0, 0.0),
486                b: (10.0, 0.0),
487            },
488            0.5,
489        );
490        assert_eq!(doc.points.len(), 2);
491        assert_eq!(doc.geometries.len(), 1);
492        assert_eq!(doc.geometries[0].geom_type, "line");
493    }
494
495    /// `emit_shape` for a bezier adds 4 points, the bezier geometry, and 2 dashed
496    /// construction guide lines.
497    #[test]
498    fn emit_bezier_adds_four_points_geometry_and_guides() {
499        let mut doc = SketchDoc::default();
500        emit_shape(
501            &mut doc,
502            &HandDrawShape::Bezier {
503                controls: [(0.0, 0.0), (1.0, 2.0), (3.0, 2.0), (4.0, 0.0)],
504            },
505            0.1,
506        );
507        assert_eq!(doc.points.len(), 4);
508        // bezier + 2 construction guide lines.
509        assert_eq!(doc.geometries.len(), 3);
510        assert_eq!(doc.geometries[0].geom_type, "bezier");
511        assert!(doc.geometries[1].construction() && doc.geometries[2].construction());
512    }
513
514    /// An emitted endpoint that lands within `snap_radius` of an EXISTING point reuses
515    /// that point's id (auto-coincident via id reuse).
516    #[test]
517    fn emit_snaps_endpoint_onto_existing_point() {
518        let mut doc: SketchDoc = serde_json::from_value(serde_json::json!({
519            "points": [{ "id": 42, "x": 0.0, "y": 0.0 }],
520            "geometries": [],
521            "constraints": []
522        }))
523        .unwrap();
524        // A line whose start sits ~near the existing point 42, end far away.
525        emit_shape(
526            &mut doc,
527            &HandDrawShape::Line {
528                a: (0.05, 0.0),
529                b: (10.0, 0.0),
530            },
531            0.5,
532        );
533        // The start reused id 42 (no new point for it); only the far end is fresh.
534        assert_eq!(doc.points.len(), 2);
535        let line = &doc.geometries[0];
536        assert_eq!(id_key(&line.points[0]), "42");
537        assert_ne!(id_key(&line.points[1]), "42");
538    }
539
540    /// A shape's OWN points never collapse onto each other even when close (only
541    /// pre-existing points are snap targets).
542    #[test]
543    fn emit_does_not_collapse_own_points() {
544        let mut doc = SketchDoc::default();
545        // Bezier controls within the snap radius of one another.
546        emit_shape(
547            &mut doc,
548            &HandDrawShape::Bezier {
549                controls: [(0.0, 0.0), (0.1, 0.0), (0.2, 0.0), (0.3, 0.0)],
550            },
551            5.0,
552        );
553        assert_eq!(doc.points.len(), 4, "own control points must stay distinct");
554    }
555
556    #[test]
557    fn stroke_extent_is_the_bbox_diagonal() {
558        let ext = stroke_extent(&[(0.0, 0.0), (3.0, 0.0), (3.0, 4.0)]);
559        assert!((ext - 5.0).abs() < 1e-9);
560        assert_eq!(stroke_extent(&[]), 0.0);
561    }
562}