Skip to main content

brep_render/sketch/
spline.rs

1//! Spline anchor insertion (S3) — refine an existing spline by subdividing it.
2//!
3//! A sketch spline is geometry `type: "bezier"` (`"spline"` is the kernel's accepted
4//! alias): a CHAINED cubic Bézier control polygon of `3n + 1` point ids, on-curve
5//! anchors at indices 0, 3, 6, … and two off-curve handles between each pair. Every
6//! id is an ordinary sketch point, so it constrains and solves like any other.
7//!
8//! The bezier TOOL authors one span per invocation (4 clicks). This module is the
9//! other half: while that tool is idle, a click along an existing spline inserts a
10//! new anchor there. The user is REFINING a curve they already like, so the
11//! insertion must not move the curve by a pixel — which is why this is de Casteljau
12//! subdivision and not an append. Splitting span `P0 P1 P2 P3` at parameter `t`:
13//!
14//! ```text
15//! A = lerp(P0,P1,t)  B = lerp(P1,P2,t)  C = lerp(P2,P3,t)
16//! D = lerp(A,B,t)    E = lerp(B,C,t)    S = lerp(D,E,t)
17//! ```
18//!
19//! gives the two cubics `P0 A D S` and `S E C P3`, which together trace exactly the
20//! original curve. In the chained polygon that is `P0, A, D, S, E, C, P3` — three
21//! ids longer, so the `3n + 1` invariant is preserved and the span count grows by one.
22//!
23//! ## Point-id policy
24//! Constraints reference point IDS, so subdivision keeps as many as it can: `P0` and
25//! `P3` are untouched, and `P1`/`P2` are REUSED for `A`/`C` — they remain the handles
26//! flanking the same two anchors and merely slide inward, so a tangent, guide line or
27//! dimension hung on them survives and still means what it meant. `A` stays on the
28//! ray `P0→P1` (it is `lerp(P0,P1,t)`), so even the solver's end-tangent convention
29//! (`B'(0) = 3(P1 − P0)`, see the kernel's `spline_end_info`) reads the same direction
30//! after the insert as before. Only `D`, `S` and `E` are freshly minted.
31//!
32//! The interior anchor the split creates is exactly collinear with its two handles
33//! (`S = lerp(D, E, t)`), which is precisely the implied G1 join the kernel solver
34//! pushes for every interior anchor (a temporary `⏛` over `prev_handle, next_handle,
35//! anchor`). The insert therefore lands on a zero-residual configuration and the
36//! following solve has nothing to correct.
37
38use serde_json::Value;
39
40use super::doc::{id_key, SketchDoc, SketchPoint};
41
42/// Per-span sampling resolution used to locate the click along a spline — the same
43/// 64 the overlay tessellator draws a span with, so "where the curve looks like it
44/// is" and "where the click lands on it" agree. The chord projection inside each
45/// sample interval recovers the parameter to far better than the sample spacing.
46const SPAN_SAMPLES: usize = 64;
47
48/// Refuse a subdivision within this much of either end of the clicked SPAN (in that
49/// span's own `0..1` parameter). Splitting at the very end mints a zero-length span,
50/// which is a degenerate control polygon, not a refinement. In practice the
51/// point-priority rule in [`insert_anchor`] catches these clicks first — an anchor is
52/// a point, and points win the pick — so this is the backstop for the sliver between
53/// the two radii.
54const MIN_SPAN_PARAM: f64 = 1e-3;
55
56/// Whether `geom_type` names a spline. Mirrors the kernel solver's
57/// `is_spline_geometry_type`: `"bezier"` is what the tools author, `"spline"` is the
58/// accepted alias. Defined here rather than imported because the sketch module is
59/// deliberately kernel-free.
60pub fn is_spline_type(geom_type: &str) -> bool {
61    geom_type == "bezier" || geom_type == "spline"
62}
63
64/// The ids [`insert_anchor`] minted: the new on-curve `anchor` and the two off-curve
65/// handles flanking it (`before` precedes it in the polygon, `after` follows it). The
66/// caller decides what to hang on them — the tool draws each anchor's handle guides.
67#[derive(Clone, Debug)]
68pub struct InsertedAnchor {
69    /// The new on-curve anchor (`S`).
70    pub anchor: Value,
71    /// The handle immediately BEFORE the anchor (`D`) — it ends the first half-span.
72    pub before: Value,
73    /// The handle immediately AFTER the anchor (`E`) — it starts the second half-span.
74    pub after: Value,
75}
76
77/// Insert an anchor into the spline under the plane click `(u, v)`, or `None` when
78/// the click should mean something else. Shape-preserving: the curve through the
79/// grown control polygon is the curve that was there before.
80///
81/// The picking rules, in order:
82/// * A POINT within `radius` wins — the whole sketcher gives points priority over
83///   geometry, and a click on an anchor or handle would split at a span end anyway.
84/// * Otherwise the nearest SPLINE within `radius` is the target. Only splines are
85///   candidates, deliberately: the bezier tool hangs a dashed construction guide off
86///   each end handle, and on a shallow spline (near-collinear handles) those guides
87///   hug the curve along its whole length. Ranking all geometry together would let a
88///   spline's own guides shadow it and make the very splines a user most wants to
89///   refine the ones that cannot be refined.
90/// * The clicked span must have four DISTINCT ids and the click must land clear of
91///   the span's ends ([`MIN_SPAN_PARAM`]) — a cusp built by repeating an id cannot be
92///   subdivided without corrupting the other role, and a split at a span end is
93///   degenerate.
94///
95/// Does NOT solve, refresh or record undo — the engine wrapper owns that.
96pub fn insert_anchor(doc: &mut SketchDoc, u: f64, v: f64, radius: f64) -> Option<InsertedAnchor> {
97    // Points win the pick, exactly as they do for hover, select, drag and trim — the
98    // sketcher has ONE priority rule and a spline click does not get to break it.
99    if doc
100        .points
101        .iter()
102        .any(|p| (p.x - u).hypot(p.y - v) <= radius)
103    {
104        return None;
105    }
106
107    let (geo_id, span, t) = nearest_spline_span(doc, u, v, radius)?;
108    if !(MIN_SPAN_PARAM..=1.0 - MIN_SPAN_PARAM).contains(&t) {
109        return None;
110    }
111
112    // Resolve the clicked span's four ids + coordinates BEFORE any mutation: the
113    // splice below shifts every id after the split point.
114    let ids = {
115        let geo = doc.geometry(&geo_id)?;
116        let i0 = span * 3;
117        [
118            geo.points.get(i0)?.clone(),
119            geo.points.get(i0 + 1)?.clone(),
120            geo.points.get(i0 + 2)?.clone(),
121            geo.points.get(i0 + 3)?.clone(),
122        ]
123    };
124    let keys: Vec<String> = ids.iter().map(id_key).collect();
125    for i in 0..keys.len() {
126        for j in (i + 1)..keys.len() {
127            if keys[i] == keys[j] {
128                return None; // a repeated id plays two roles — subdividing would corrupt one.
129            }
130        }
131    }
132    let controls = span_controls(doc, &ids)?;
133
134    // de Casteljau at `t` — the two halves' control polygons.
135    let [p0, p1, p2, p3] = controls;
136    let a = lerp(p0, p1, t);
137    let b = lerp(p1, p2, t);
138    let c = lerp(p2, p3, t);
139    let d = lerp(a, b, t);
140    let e = lerp(b, c, t);
141    let s = lerp(d, e, t);
142
143    // The old handles slide inward onto the new half-spans' outer handles, keeping
144    // their ids (and everything constrained to them) attached to the same curve ends.
145    // Both resolved a moment ago through `span_controls`, so neither move can miss.
146    move_point(doc, &ids[1], a)?;
147    move_point(doc, &ids[2], c)?;
148    // Minted in polygon order so the ids read left-to-right along the curve.
149    let d_id = add_point(doc, d);
150    let s_id = add_point(doc, s);
151    let e_id = add_point(doc, e);
152
153    // `P0 A | P3` → `P0 A D S E C P3`: the three new ids go between the two reused
154    // handles, i.e. right after slot `i0 + 1`.
155    let geo = doc.geometry_mut(&geo_id)?;
156    let at = span * 3 + 2;
157    geo.points
158        .splice(at..at, [d_id.clone(), s_id.clone(), e_id.clone()]);
159
160    Some(InsertedAnchor { anchor: s_id, before: d_id, after: e_id })
161}
162
163/// The `(geometry id, span index, span parameter)` of the point nearest `(u, v)` over
164/// every spline in `doc`, or `None` when none passes within `radius`. Splines whose
165/// control count violates `3n + 1` are skipped — they are corrupt, not pickable.
166fn nearest_spline_span(doc: &SketchDoc, u: f64, v: f64, radius: f64) -> Option<(Value, usize, f64)> {
167    let mut best: Option<(f64, Value, usize, f64)> = None;
168    for geo in &doc.geometries {
169        if !is_spline_type(&geo.geom_type) {
170            continue;
171        }
172        let ids = &geo.points;
173        if ids.len() < 4 || (ids.len() - 1) % 3 != 0 {
174            continue;
175        }
176        for span in 0..(ids.len() - 1) / 3 {
177            let i0 = span * 3;
178            let Some(controls) = span_controls(
179                doc,
180                &[
181                    ids[i0].clone(),
182                    ids[i0 + 1].clone(),
183                    ids[i0 + 2].clone(),
184                    ids[i0 + 3].clone(),
185                ],
186            ) else {
187                continue;
188            };
189            let (dist, t) = closest_on_span(&controls, u, v);
190            if dist <= radius && best.as_ref().map_or(true, |(bd, ..)| dist < *bd) {
191                best = Some((dist, geo.id.clone(), span, t));
192            }
193        }
194    }
195    best.map(|(_, id, span, t)| (id, span, t))
196}
197
198/// The `(distance, parameter)` of the point on one cubic span nearest `(u, v)`.
199/// Sampled at [`SPAN_SAMPLES`] chords, then the click is projected onto the winning
200/// chord — the same sampled-polyline treatment trim gives a curve, and accurate to
201/// the chord's sagitta (well under a pixel at overlay resolution).
202fn closest_on_span(controls: &[[f64; 2]; 4], u: f64, v: f64) -> (f64, f64) {
203    let mut prev = eval_cubic(controls, 0.0);
204    let mut best = ((u - prev[0]).hypot(v - prev[1]), 0.0);
205    for i in 1..=SPAN_SAMPLES {
206        let t1 = i as f64 / SPAN_SAMPLES as f64;
207        let next = eval_cubic(controls, t1);
208        let (dx, dy) = (next[0] - prev[0], next[1] - prev[1]);
209        let len2 = (dx * dx + dy * dy).max(1e-24);
210        let s = (((u - prev[0]) * dx + (v - prev[1]) * dy) / len2).clamp(0.0, 1.0);
211        let dist = (u - (prev[0] + dx * s)).hypot(v - (prev[1] + dy * s));
212        if dist < best.0 {
213            let t0 = (i - 1) as f64 / SPAN_SAMPLES as f64;
214            best = (dist, t0 + (t1 - t0) * s);
215        }
216        prev = next;
217    }
218    best
219}
220
221/// Evaluate the cubic Bernstein sum over one span's four control points.
222fn eval_cubic(controls: &[[f64; 2]; 4], t: f64) -> [f64; 2] {
223    let mt = 1.0 - t;
224    let (w0, w1, w2, w3) = (
225        mt * mt * mt,
226        3.0 * mt * mt * t,
227        3.0 * mt * t * t,
228        t * t * t,
229    );
230    [
231        w0 * controls[0][0] + w1 * controls[1][0] + w2 * controls[2][0] + w3 * controls[3][0],
232        w0 * controls[0][1] + w1 * controls[1][1] + w2 * controls[2][1] + w3 * controls[3][1],
233    ]
234}
235
236/// Resolve four control-point ids to coordinates, or `None` if any is missing.
237fn span_controls(doc: &SketchDoc, ids: &[Value; 4]) -> Option<[[f64; 2]; 4]> {
238    let mut out = [[0.0; 2]; 4];
239    for (slot, id) in ids.iter().enumerate() {
240        let p = doc.point(id)?;
241        out[slot] = [p.x, p.y];
242    }
243    Some(out)
244}
245
246fn lerp(a: [f64; 2], b: [f64; 2], t: f64) -> [f64; 2] {
247    [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
248}
249
250/// Move an existing point to `(x, y)`, or `None` if it vanished.
251fn move_point(doc: &mut SketchDoc, id: &Value, at: [f64; 2]) -> Option<()> {
252    let p = doc.point_mut(id)?;
253    p.x = at[0];
254    p.y = at[1];
255    Some(())
256}
257
258/// Mint a fresh free point at `at` and return its id. Never snaps to a neighbour
259/// (unlike [`SketchDoc::snap_or_add_point`]): a subdivision's three new control points
260/// are new roles in the polygon even when they land on top of something.
261fn add_point(doc: &mut SketchDoc, at: [f64; 2]) -> Value {
262    let id = doc.next_point_id();
263    doc.points.push(SketchPoint {
264        id: id.clone(),
265        x: at[0],
266        y: at[1],
267        fixed: false,
268        construction: false,
269        external_reference: false,
270    });
271    id
272}
273
274// ---------------------------------------------------------------------------
275// Test-only geometry readers.
276//
277// Shared by this module's unit tests AND the engine-level tool tests
278// (`engine_state::sketch_mode_tests`), so "the curve did not move" is measured by
279// ONE implementation in both places — analytically, against the control polygons,
280// never through a sampled polyline (whose chord gaps run to ~1e-2 at any sane sample
281// count and would swallow the deviation these tests exist to catch).
282// ---------------------------------------------------------------------------
283
284/// One span of a spline geometry as a control polygon. Panics on a missing point or
285/// a short polygon — a test fixture that malformed is a broken test, not a case.
286#[cfg(test)]
287pub(crate) fn span_controls_at(doc: &SketchDoc, geo_id: &Value, span: usize) -> [[f64; 2]; 4] {
288    let geo = doc.geometry(geo_id).expect("geometry");
289    let i0 = span * 3;
290    let ids = [
291        geo.points[i0].clone(),
292        geo.points[i0 + 1].clone(),
293        geo.points[i0 + 2].clone(),
294        geo.points[i0 + 3].clone(),
295    ];
296    span_controls(doc, &ids).expect("controls")
297}
298
299/// Every span of a spline geometry as a control polygon, in chain order.
300#[cfg(test)]
301pub(crate) fn chain_spans(doc: &SketchDoc, geo_id: &Value) -> Vec<[[f64; 2]; 4]> {
302    let count = (doc.geometry(geo_id).expect("geometry").points.len() - 1) / 3;
303    (0..count).map(|span| span_controls_at(doc, geo_id, span)).collect()
304}
305
306/// The point at parameter `t` on span `span` of a spline geometry — how a test aims a
307/// click AT the curve instead of hard-coding a coordinate that a later edit to the
308/// fixture would silently move off it.
309#[cfg(test)]
310pub(crate) fn point_on_span(doc: &SketchDoc, geo_id: &Value, span: usize, t: f64) -> [f64; 2] {
311    eval_cubic(&span_controls_at(doc, geo_id, span), t)
312}
313
314/// The true distance from `q` to the cubic `controls`: a coarse parameter scan for the
315/// bracketing interval, then ternary search inside it (the squared distance is
316/// unimodal there). Exact to machine precision, unlike a sampled polyline's
317/// nearest-VERTEX distance.
318#[cfg(test)]
319fn dist_to_cubic(controls: &[[f64; 2]; 4], q: [f64; 2]) -> f64 {
320    const SCAN: usize = 256;
321    let at = |t: f64| {
322        let p = eval_cubic(controls, t);
323        (p[0] - q[0]).hypot(p[1] - q[1])
324    };
325    let mut best = (at(0.0), 0.0);
326    for i in 1..=SCAN {
327        let t = i as f64 / SCAN as f64;
328        let d = at(t);
329        if d < best.0 {
330            best = (d, t);
331        }
332    }
333    let h = 1.0 / SCAN as f64;
334    let (mut lo, mut hi) = ((best.1 - h).max(0.0), (best.1 + h).min(1.0));
335    for _ in 0..200 {
336        let a = lo + (hi - lo) / 3.0;
337        let b = hi - (hi - lo) / 3.0;
338        if at(a) < at(b) {
339            hi = b;
340        } else {
341            lo = a;
342        }
343    }
344    at((lo + hi) / 2.0)
345}
346
347/// The two-sided Hausdorff distance between the chain `spans` and the single cubic
348/// `original` — each walked densely and measured against the OTHER curve
349/// analytically. Two-sided because a subdivision could in principle cover only part
350/// of the original and still score zero one way.
351#[cfg(test)]
352pub(crate) fn shape_deviation(spans: &[[[f64; 2]; 4]], original: &[[f64; 2]; 4]) -> f64 {
353    const WALK: usize = 400;
354    let mut worst: f64 = 0.0;
355    for controls in spans {
356        for i in 0..=WALK {
357            let q = eval_cubic(controls, i as f64 / WALK as f64);
358            worst = worst.max(dist_to_cubic(original, q));
359        }
360    }
361    for i in 0..=WALK {
362        let q = eval_cubic(original, i as f64 / WALK as f64);
363        let near = spans
364            .iter()
365            .map(|c| dist_to_cubic(c, q))
366            .fold(f64::INFINITY, f64::min);
367        worst = worst.max(near);
368    }
369    worst
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use serde_json::json;
376
377    fn doc_from(value: Value) -> SketchDoc {
378        serde_json::from_value(value).expect("sketch doc")
379    }
380
381    /// A single-span spline `(0,0) (0,30) (30,30) (30,0)` — a symmetric hump whose
382    /// mid-curve point is (15, 22.5), well clear of every control point.
383    fn hump_doc() -> SketchDoc {
384        doc_from(json!({
385            "points": [
386                { "id": 0, "x": 0.0,  "y": 0.0 },
387                { "id": 1, "x": 0.0,  "y": 30.0 },
388                { "id": 2, "x": 30.0, "y": 30.0 },
389                { "id": 3, "x": 30.0, "y": 0.0 }
390            ],
391            "geometries": [{ "id": 10, "type": "bezier", "points": [0, 1, 2, 3] }],
392            "constraints": []
393        }))
394    }
395
396    fn point_ids(doc: &SketchDoc, geo_id: &Value) -> Vec<String> {
397        doc.geometry(geo_id)
398            .expect("geometry")
399            .points
400            .iter()
401            .map(id_key)
402            .collect()
403    }
404
405    #[test]
406    fn insert_subdivides_without_moving_the_curve() {
407        let mut doc = hump_doc();
408        let geo = json!(10);
409        let before = span_controls_at(&doc, &geo, 0);
410
411        // Click the apex (15, 22.5) — exactly on the curve at t = 0.5.
412        let added = insert_anchor(&mut doc, 15.0, 22.5, 1.0).expect("inserted");
413        assert_eq!(point_ids(&doc, &geo).len(), 7, "3n+1 grew by one span");
414
415        // The subdivided polygon traces the ORIGINAL cubic. Pure doc math (no solver
416        // rounding in the way), so this is floating-point noise, not a tolerance: the
417        // observed deviation here and in every other insert test is ~1.6e-14, and the
418        // band is set two orders above that so a REAL drift cannot hide under it.
419        let dev = shape_deviation(&chain_spans(&doc, &geo), &before);
420        assert!(dev < 1e-12, "curve moved by {dev}");
421
422        // The new anchor landed under the click and is collinear with its handles —
423        // the implied G1 join the kernel pushes at every interior anchor.
424        let s = doc.point(&added.anchor).expect("anchor");
425        assert!((s.x - 15.0).abs() < 1e-6 && (s.y - 22.5).abs() < 1e-6, "anchor at {s:?}");
426        let d = doc.point(&added.before).expect("before handle");
427        let e = doc.point(&added.after).expect("after handle");
428        let cross = (s.x - d.x) * (e.y - d.y) - (s.y - d.y) * (e.x - d.x);
429        assert!(cross.abs() < 1e-9, "anchor off the handle line: {cross}");
430    }
431
432    #[test]
433    fn insert_keeps_the_end_ids_and_reuses_the_handle_ids() {
434        let mut doc = hump_doc();
435        let geo = json!(10);
436        insert_anchor(&mut doc, 15.0, 22.5, 1.0).expect("inserted");
437
438        let ids = point_ids(&doc, &geo);
439        assert_eq!(ids[0], "0", "start anchor id untouched");
440        assert_eq!(ids[6], "3", "end anchor id untouched");
441        assert_eq!(ids[1], "1", "P1 reused as the first half-span's handle");
442        assert_eq!(ids[5], "2", "P2 reused as the second half-span's handle");
443        // The reused handles slid along their own tangent rays, so the end-tangent
444        // DIRECTIONS (the kernel's `B'(0) = 3(P1 - P0)` convention) are unchanged.
445        let p1 = doc.point(&json!(1)).expect("P1");
446        assert!((p1.x - 0.0).abs() < 1e-9 && (p1.y - 15.0).abs() < 1e-9, "A = {p1:?}");
447        let p2 = doc.point(&json!(2)).expect("P2");
448        assert!((p2.x - 30.0).abs() < 1e-9 && (p2.y - 15.0).abs() < 1e-9, "C = {p2:?}");
449        // Exactly three points minted (D, S, E).
450        assert_eq!(doc.points.len(), 7);
451    }
452
453    #[test]
454    fn insert_into_the_second_span_splices_at_the_right_slot() {
455        // Two spans sharing anchor 3; insert into the SECOND one. An off-by-3 splice
456        // passes every single-span test, so this is the one that catches it.
457        let mut doc = doc_from(json!({
458            "points": [
459                { "id": 0, "x": 0.0,  "y": 0.0 },
460                { "id": 1, "x": 0.0,  "y": 20.0 },
461                { "id": 2, "x": 20.0, "y": 20.0 },
462                { "id": 3, "x": 20.0, "y": 0.0 },
463                { "id": 4, "x": 20.0, "y": -20.0 },
464                { "id": 5, "x": 40.0, "y": -20.0 },
465                { "id": 6, "x": 40.0, "y": 0.0 }
466            ],
467            "geometries": [{ "id": 10, "type": "bezier", "points": [0, 1, 2, 3, 4, 5, 6] }],
468            "constraints": []
469        }));
470        let geo = json!(10);
471        let span0_before = span_controls_at(&doc, &geo, 0);
472        let span1_before = span_controls_at(&doc, &geo, 1);
473        let mid = eval_cubic(&span1_before, 0.5);
474
475        insert_anchor(&mut doc, mid[0], mid[1], 1.0).expect("inserted");
476        let ids = point_ids(&doc, &geo);
477        assert_eq!(ids.len(), 10, "3n+1 holds at three spans");
478        // The FIRST span's four ids are untouched; the split happened after slot 4.
479        assert_eq!(&ids[..4], &["0", "1", "2", "3"], "first span disturbed: {ids:?}");
480        assert_eq!(ids[4], "4", "second span's leading handle kept its id");
481        assert_eq!(ids[8], "5", "second span's trailing handle kept its id");
482        assert_eq!(ids[9], "6", "end anchor untouched");
483
484        // The untouched span kept its control points verbatim, and the subdivided one
485        // is reproduced by the two spans that replaced it.
486        assert_eq!(span_controls_at(&doc, &geo, 0), span0_before, "first span moved");
487        let dev = shape_deviation(&chain_spans(&doc, &geo)[1..=2], &span1_before);
488        assert!(dev < 1e-12, "subdivided span moved by {dev}");
489    }
490
491    #[test]
492    fn a_second_insert_into_a_freshly_made_span_still_holds_the_invariant() {
493        let mut doc = hump_doc();
494        let geo = json!(10);
495        let original = span_controls_at(&doc, &geo, 0);
496
497        insert_anchor(&mut doc, 15.0, 22.5, 1.0).expect("first insert");
498        // Halfway along the FIRST half-span (a span that did not exist a moment ago).
499        let mid = eval_cubic(&span_controls_at(&doc, &geo, 0), 0.5);
500        insert_anchor(&mut doc, mid[0], mid[1], 1.0).expect("second insert");
501        assert_eq!(point_ids(&doc, &geo).len(), 10, "3n+1 after two inserts");
502
503        // …and two inserts later the curve is still the curve that was drawn.
504        let dev = shape_deviation(&chain_spans(&doc, &geo), &original);
505        assert!(dev < 1e-12, "curve moved by {dev} over two inserts");
506    }
507
508    #[test]
509    fn two_inserts_into_the_same_original_span_both_land() {
510        let mut doc = hump_doc();
511        let geo = json!(10);
512        let original = span_controls_at(&doc, &geo, 0);
513        // Quarter and three-quarter points of the ORIGINAL span, in that order: the
514        // second click falls in the second half-span the first insert created.
515        let first = eval_cubic(&original, 0.25);
516        let second = eval_cubic(&original, 0.75);
517        insert_anchor(&mut doc, first[0], first[1], 1.0).expect("first insert");
518        insert_anchor(&mut doc, second[0], second[1], 1.0).expect("second insert");
519
520        assert_eq!(point_ids(&doc, &geo).len(), 10, "two spans became four");
521        let dev = shape_deviation(&chain_spans(&doc, &geo), &original);
522        assert!(dev < 1e-12, "curve moved by {dev}");
523    }
524
525    #[test]
526    fn a_click_off_the_curve_or_on_another_geometry_inserts_nothing() {
527        let mut doc = hump_doc();
528        // Far from everything.
529        assert!(insert_anchor(&mut doc, 15.0, 0.5, 1.0).is_none(), "empty space inserted");
530        assert_eq!(doc.points.len(), 4, "nothing minted");
531
532        // A line laid across the sketch, clicked well away from the spline.
533        let mut doc = doc_from(json!({
534            "points": [
535                { "id": 0, "x": 0.0,  "y": 0.0 },
536                { "id": 1, "x": 0.0,  "y": 30.0 },
537                { "id": 2, "x": 30.0, "y": 30.0 },
538                { "id": 3, "x": 30.0, "y": 0.0 },
539                { "id": 4, "x": -50.0, "y": -10.0 },
540                { "id": 5, "x": 50.0,  "y": -10.0 }
541            ],
542            "geometries": [
543                { "id": 10, "type": "bezier", "points": [0, 1, 2, 3] },
544                { "id": 11, "type": "line", "points": [4, 5] }
545            ],
546            "constraints": []
547        }));
548        assert!(insert_anchor(&mut doc, 0.0, -10.0, 1.0).is_none(), "the line was subdivided");
549        assert_eq!(doc.points.len(), 6, "nothing minted");
550    }
551
552    #[test]
553    fn a_click_on_a_control_point_or_span_end_inserts_nothing() {
554        let mut doc = hump_doc();
555        // Points win the pick: a click on the start anchor is not a subdivision.
556        assert!(insert_anchor(&mut doc, 0.0, 0.0, 1.0).is_none(), "anchor click inserted");
557        // …nor is one on an off-curve handle.
558        assert!(insert_anchor(&mut doc, 0.2, 30.0, 1.0).is_none(), "handle click inserted");
559        assert_eq!(doc.points.len(), 4, "nothing minted");
560    }
561
562    #[test]
563    fn a_cusp_span_that_repeats_an_id_is_left_alone() {
564        // Handle id 1 doubles as the second handle: subdividing would need to move it
565        // to two different places at once, so the click is refused.
566        let mut doc = doc_from(json!({
567            "points": [
568                { "id": 0, "x": 0.0,  "y": 0.0 },
569                { "id": 1, "x": 15.0, "y": 30.0 },
570                { "id": 3, "x": 30.0, "y": 0.0 }
571            ],
572            "geometries": [{ "id": 10, "type": "bezier", "points": [0, 1, 1, 3] }],
573            "constraints": []
574        }));
575        let mid = eval_cubic(&span_controls_at(&doc, &json!(10), 0), 0.5);
576        assert!(insert_anchor(&mut doc, mid[0], mid[1], 1.0).is_none(), "cusp subdivided");
577        assert_eq!(doc.points.len(), 3, "nothing minted");
578    }
579
580    #[test]
581    fn the_spline_type_alias_is_pickable_too() {
582        let mut doc = doc_from(json!({
583            "points": [
584                { "id": 0, "x": 0.0,  "y": 0.0 },
585                { "id": 1, "x": 0.0,  "y": 30.0 },
586                { "id": 2, "x": 30.0, "y": 30.0 },
587                { "id": 3, "x": 30.0, "y": 0.0 }
588            ],
589            "geometries": [{ "id": 10, "type": "spline", "points": [0, 1, 2, 3] }],
590            "constraints": []
591        }));
592        assert!(insert_anchor(&mut doc, 15.0, 22.5, 1.0).is_some(), "alias not subdivided");
593        assert_eq!(point_ids(&doc, &json!(10)).len(), 7);
594    }
595}