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