Skip to main content

brep_render/sketch/
handdraw.rs

1//! Recognize freehand strokes as lines, circles, arcs, or cubic Beziers.
2//!
3//! Classification tolerances scale with the stroke's bounding-box extent.
4//! Open strokes near their endpoint chord become lines. A good least-squares
5//! circle fit produces a circle for closed strokes or an arc for open strokes.
6//! The fallback cubic uses endpoints and samples at one-third and two-thirds
7//! of the stroke's arc length.
8
9use crate::geometry2d::{distance as dist, point_segment_distance};
10
11use serde_json::{Map, Value};
12
13use super::doc::{SketchDoc, SketchGeometry, SketchPoint};
14
15/// Endpoint gap (as a fraction of the stroke extent) under which the stroke counts
16/// as CLOSED — a freehand loop rarely returns to its exact start.
17const CLOSED_GAP_FRAC: f64 = 0.15;
18/// Max interior perpendicular deviation from the endpoint chord (fraction of extent)
19/// for the stroke to read as a straight LINE.
20const LINE_DEV_FRAC: f64 = 0.02;
21/// Max radial residual of the circle fit (fraction of extent) for the stroke to read
22/// as a CIRCLE / ARC — loose enough to absorb hand wobble.
23const CIRCLE_RESIDUAL_FRAC: f64 = 0.08;
24/// A fitted radius below this fraction of the extent is a degenerate dot, not a
25/// circle.
26const MIN_RADIUS_FRAC: f64 = 0.02;
27
28/// A recognized handdraw shape, in plane `(u, v)` coordinates. Point semantics match
29/// the solver's geometry (`line = [a, b]`, `circle = [center, rim]`,
30/// `arc = [center, start, end]`, `bezier = [p0, c1, c2, p1]`).
31#[derive(Clone, Debug, PartialEq)]
32pub enum HandDrawShape {
33    /// A straight segment through the stroke's two endpoints.
34    Line { a: (f64, f64), b: (f64, f64) },
35    /// A closed circle — the fitted center + a rim point on `+u`.
36    Circle { center: (f64, f64), rim: (f64, f64) },
37    /// A circular arc — the fitted center + the stroke's two endpoints. An arc always
38    /// runs CCW from `start` to `end`, so a stroke drawn clockwise stores its last
39    /// sample as `start` and its first as `end`: the arc covers the drawn path.
40    Arc {
41        center: (f64, f64),
42        start: (f64, f64),
43        end: (f64, f64),
44    },
45    /// A cubic Bezier fallback: `[p0, c1, c2, p1]` (endpoints + two on-curve controls).
46    Bezier { controls: [(f64, f64); 4] },
47}
48
49impl HandDrawShape {
50    /// The solver geometry `type` this shape materializes as.
51    pub fn kind(&self) -> &'static str {
52        match self {
53            HandDrawShape::Line { .. } => "line",
54            HandDrawShape::Circle { .. } => "circle",
55            HandDrawShape::Arc { .. } => "arc",
56            HandDrawShape::Bezier { .. } => "bezier",
57        }
58    }
59}
60
61/// Classify a raw uv `stroke` into one recognized [`HandDrawShape`]. See the module
62/// docs for the tolerance rules. A stroke of fewer than 2 samples degenerates to a
63/// zero-length line (callers guard against tiny strokes before recognizing).
64pub fn recognize(stroke: &[(f64, f64)]) -> HandDrawShape {
65    let n = stroke.len();
66    if n < 2 {
67        let p = stroke.first().copied().unwrap_or((0.0, 0.0));
68        return HandDrawShape::Line { a: p, b: p };
69    }
70    let a = stroke[0];
71    let b = stroke[n - 1];
72    let extent = stroke_extent(stroke).max(1e-9);
73    let closed = dist(a, b) <= CLOSED_GAP_FRAC * extent;
74
75    // LINE — an open stroke whose interior hugs the endpoint chord (a 2-sample stroke
76    // is trivially straight).
77    if !closed {
78        let max_dev = stroke[1..n - 1]
79            .iter()
80            .map(|&p| point_segment_distance(p, a, b).0)
81            .fold(0.0_f64, f64::max);
82        if n == 2 || max_dev <= LINE_DEV_FRAC * extent {
83            return HandDrawShape::Line { a, b };
84        }
85    }
86
87    // CIRCLE / ARC — a good least-squares circle fit (non-degenerate radius, small
88    // radial residual). Closed → a full circle; open → an arc through the endpoints.
89    if n >= 3 {
90        if let Some((cx, cy, r)) = fit_circle_lsq(stroke) {
91            let residual = stroke
92                .iter()
93                .map(|&p| (dist(p, (cx, cy)) - r).abs())
94                .fold(0.0_f64, f64::max);
95            if r.is_finite()
96                && r > MIN_RADIUS_FRAC * extent
97                && residual <= CIRCLE_RESIDUAL_FRAC * extent
98            {
99                if closed {
100                    return HandDrawShape::Circle {
101                        center: (cx, cy),
102                        rim: (cx + r, cy),
103                    };
104                }
105                let (start, end) = if drawn_sweep(stroke, (cx, cy)) < 0.0 {
106                    (b, a)
107                } else {
108                    (a, b)
109                };
110                return HandDrawShape::Arc {
111                    center: (cx, cy),
112                    start,
113                    end,
114                };
115            }
116        }
117    }
118
119    // BEZIER fallback — a single cubic through the stroke.
120    HandDrawShape::Bezier {
121        controls: fit_cubic(stroke),
122    }
123}
124
125/// The signed angle the stroke turns through about `center`, in radians: the sum of
126/// the angles between consecutive radius vectors, positive for counter-clockwise. The
127/// sign is the drawn direction at any sweep, including near a half circle, where the
128/// two candidate arcs have about the same length.
129fn drawn_sweep(stroke: &[(f64, f64)], center: (f64, f64)) -> f64 {
130    stroke
131        .windows(2)
132        .map(|pair| {
133            let (ux, uy) = (pair[0].0 - center.0, pair[0].1 - center.1);
134            let (vx, vy) = (pair[1].0 - center.0, pair[1].1 - center.1);
135            (ux * vy - uy * vx).atan2(ux * vx + uy * vy)
136        })
137        .sum()
138}
139
140/// Materialize a recognized [`HandDrawShape`] into `doc`: mint its points (endpoints
141/// snap to an existing PRE-STROKE point within `snap_radius` so a stroke drawn onto
142/// prior geometry coincides) and append the geometry (non-construction; the bezier
143/// fallback also adds its two dashed control-handle guide lines, matching the bezier
144/// tool). Never solves; the caller re-solves.
145pub fn emit_shape(doc: &mut SketchDoc, shape: &HandDrawShape, snap_radius: f64) {
146    // Only points that existed BEFORE this emit are snap targets, so a shape's own
147    // freshly-minted points never collapse into one another (a short stroke's
148    // endpoints / a bezier's controls stay distinct).
149    let base = doc.points.len();
150    match shape {
151        HandDrawShape::Line { a, b } => {
152            let a_id = snap_new_point(doc, base, a.0, a.1, snap_radius);
153            let b_id = snap_new_point(doc, base, b.0, b.1, snap_radius);
154            push_geometry(doc, "line", vec![a_id, b_id], false);
155        }
156        HandDrawShape::Circle { center, rim } => {
157            let c = snap_new_point(doc, base, center.0, center.1, snap_radius);
158            let r = snap_new_point(doc, base, rim.0, rim.1, snap_radius);
159            push_geometry(doc, "circle", vec![c, r], false);
160        }
161        HandDrawShape::Arc { center, start, end } => {
162            let c = snap_new_point(doc, base, center.0, center.1, snap_radius);
163            let s = snap_new_point(doc, base, start.0, start.1, snap_radius);
164            let e = snap_new_point(doc, base, end.0, end.1, snap_radius);
165            push_geometry(doc, "arc", vec![c, s, e], false);
166        }
167        HandDrawShape::Bezier { controls } => {
168            let ids: Vec<Value> = controls
169                .iter()
170                .map(|&(u, v)| snap_new_point(doc, base, u, v, snap_radius))
171                .collect();
172            push_geometry(doc, "bezier", ids.clone(), false);
173            // Dashed control-handle guides (end0→ctrl0, end1→ctrl1) — matches the
174            // click-driven bezier tool.
175            push_geometry(doc, "line", vec![ids[0].clone(), ids[1].clone()], true);
176            push_geometry(doc, "line", vec![ids[3].clone(), ids[2].clone()], true);
177        }
178    }
179}
180
181/// The bounding-box diagonal of a uv stroke — the relative-tolerance scale (and the
182/// engine's "too tiny to recognize" gauge).
183pub fn stroke_extent(stroke: &[(f64, f64)]) -> f64 {
184    let (mut minx, mut miny) = (f64::INFINITY, f64::INFINITY);
185    let (mut maxx, mut maxy) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
186    for &(x, y) in stroke {
187        minx = minx.min(x);
188        miny = miny.min(y);
189        maxx = maxx.max(x);
190        maxy = maxy.max(y);
191    }
192    if !minx.is_finite() {
193        return 0.0;
194    }
195    ((maxx - minx).powi(2) + (maxy - miny).powi(2)).sqrt()
196}
197
198/// Fit a cubic through the stroke: endpoints = first/last, the two controls sampled
199/// at ~1/3 and ~2/3 of the ARC LENGTH (on-curve approximation, mirroring the previous
200/// cubic-from-stroke fit). A zero-length stroke collapses to its endpoints.
201fn fit_cubic(stroke: &[(f64, f64)]) -> [(f64, f64); 4] {
202    let n = stroke.len();
203    let first = stroke[0];
204    let last = stroke[n - 1];
205    let mut cum = vec![0.0_f64; n];
206    for i in 1..n {
207        cum[i] = cum[i - 1] + dist(stroke[i - 1], stroke[i]);
208    }
209    let total = cum[n - 1];
210    if total < 1e-9 {
211        return [first, first, last, last];
212    }
213    let c1 = sample_arc(stroke, &cum, total, 1.0 / 3.0);
214    let c2 = sample_arc(stroke, &cum, total, 2.0 / 3.0);
215    [first, c1, c2, last]
216}
217
218/// Sample the stroke at fractional arc length `t ∈ [0, 1]` (linear between the two
219/// bracketing samples).
220fn sample_arc(stroke: &[(f64, f64)], cum: &[f64], total: f64, t: f64) -> (f64, f64) {
221    let target = total * t;
222    let mut idx = 0;
223    while idx < cum.len() && cum[idx] < target {
224        idx += 1;
225    }
226    if idx == 0 {
227        return stroke[0];
228    }
229    if idx >= cum.len() {
230        return stroke[stroke.len() - 1];
231    }
232    let (d0, d1) = (cum[idx - 1], cum[idx]);
233    let span = (d1 - d0).max(1e-9);
234    let tt = ((target - d0) / span).clamp(0.0, 1.0);
235    let p0 = stroke[idx - 1];
236    let p1 = stroke[idx];
237    (p0.0 + (p1.0 - p0.0) * tt, p0.1 + (p1.1 - p0.1) * tt)
238}
239
240/// A modified (centered Kåsa) least-squares circle fit over all samples: robust to
241/// hand wobble and dense sampling. Returns `(cx, cy, r)` or `None` when the samples
242/// are (near) collinear.
243fn fit_circle_lsq(pts: &[(f64, f64)]) -> Option<(f64, f64, f64)> {
244    let n = pts.len();
245    if n < 3 {
246        return None;
247    }
248    let nf = n as f64;
249    let (mut mx, mut my) = (0.0_f64, 0.0_f64);
250    for &(x, y) in pts {
251        mx += x;
252        my += y;
253    }
254    mx /= nf;
255    my /= nf;
256    // Centered moments (subtracting the centroid conditions the normal equations).
257    let (mut sxx, mut sxy, mut syy) = (0.0_f64, 0.0_f64, 0.0_f64);
258    let (mut sxz, mut syz) = (0.0_f64, 0.0_f64);
259    for &(x, y) in pts {
260        let u = x - mx;
261        let v = y - my;
262        let z = u * u + v * v;
263        sxx += u * u;
264        sxy += u * v;
265        syy += v * v;
266        sxz += u * z;
267        syz += v * z;
268    }
269    let det = sxx * syy - sxy * sxy;
270    if det.abs() < 1e-12 {
271        return None; // collinear
272    }
273    // Solve [sxx sxy; sxy syy][uc; vc] = [sxz/2; syz/2].
274    let uc = (sxz * syy - syz * sxy) / (2.0 * det);
275    let vc = (sxx * syz - sxy * sxz) / (2.0 * det);
276    let cx = uc + mx;
277    let cy = vc + my;
278    let r = (uc * uc + vc * vc + (sxx + syy) / nf).sqrt();
279    if !cx.is_finite() || !cy.is_finite() || !r.is_finite() {
280        return None;
281    }
282    Some((cx, cy, r))
283}
284
285/// Snap `(u, v)` to the nearest point among the first `base` doc points within
286/// `radius` (reusing its id so a stroke endpoint coincides with prior geometry), else
287/// mint a fresh free point. Freshly-appended points (index `>= base`) are never snap
288/// targets, so a single shape's points stay distinct.
289fn snap_new_point(doc: &mut SketchDoc, base: usize, u: f64, v: f64, radius: f64) -> Value {
290    let mut best: Option<(f64, Value)> = None;
291    for p in doc.points.iter().take(base) {
292        let d = ((p.x - u).powi(2) + (p.y - v).powi(2)).sqrt();
293        if d <= radius && best.as_ref().map_or(true, |(bd, _)| d < *bd) {
294            best = Some((d, p.id.clone()));
295        }
296    }
297    if let Some((_, id)) = best {
298        return id;
299    }
300    let id = doc.next_point_id();
301    doc.points.push(SketchPoint {
302        id: id.clone(),
303        x: u,
304        y: v,
305        fixed: false,
306        construction: false,
307        external_reference: false,
308    });
309    id
310}
311
312/// Append a geometry with a freshly minted id and an explicit `construction` flag.
313fn push_geometry(doc: &mut SketchDoc, geom_type: &str, points: Vec<Value>, construction: bool) {
314    let id = doc.next_geometry_id();
315    let mut extra = Map::new();
316    extra.insert("construction".to_string(), Value::Bool(construction));
317    doc.geometries.push(SketchGeometry {
318        id,
319        geom_type: geom_type.to_string(),
320        points,
321        extra,
322    });
323}
324