Skip to main content

brep_render/sketch/
spline.rs

1//! Insert spline anchors by de Casteljau subdivision without changing the curve.
2//!
3//! Sketch splines are chained cubic Béziers with `3n + 1` point IDs and anchors
4//! at indices 0, 3, 6, … . Splitting `P0 P1 P2 P3` at parameter `t` gives:
5//!
6//! ```text
7//! A = lerp(P0,P1,t)  B = lerp(P1,P2,t)  C = lerp(P2,P3,t)
8//! D = lerp(A,B,t)    E = lerp(B,C,t)    S = lerp(D,E,t)
9//! ```
10//!
11//! The resulting polygon `P0, A, D, S, E, C, P3` preserves the `3n + 1` invariant.
12//! `P0` and `P3` keep their IDs; `P1` and `P2` are reused for `A` and `C` so their
13//! constraints survive. Their endpoint tangent directions are unchanged. Only
14//! `D`, `S`, and `E` receive new IDs.
15//!
16//! The new anchor and its handles are collinear (`S = lerp(D,E,t)`), satisfying
17//! the solver's implicit G1 join constraint before the next solve.
18
19use serde_json::Value;
20
21use super::doc::{id_key, SketchDoc, SketchGeometry, SketchPoint};
22
23/// Per-span sampling resolution used to locate the click along a spline — the same
24/// 64 the overlay tessellator draws a span with, so "where the curve looks like it
25/// is" and "where the click lands on it" agree. The chord projection inside each
26/// sample interval recovers the parameter to far better than the sample spacing.
27const SPAN_SAMPLES: usize = 64;
28
29/// Refuse a subdivision within this much of either end of the clicked SPAN (in that
30/// span's own `0..1` parameter). Splitting at the very end mints a zero-length span,
31/// which is a degenerate control polygon, not a refinement. In practice the
32/// point-priority rule in [`insert_anchor`] catches these clicks first — an anchor is
33/// a point, and points win the pick — so this is the backstop for the sliver between
34/// the two radii.
35const MIN_SPAN_PARAM: f64 = 1e-3;
36
37/// Whether `geom_type` names a spline. Mirrors the kernel solver's
38/// `is_spline_geometry_type`: `"bezier"` is what the tools author, `"spline"` is the
39/// accepted alias. Defined here rather than imported because the sketch module is
40/// deliberately kernel-free.
41pub fn is_spline_type(geom_type: &str) -> bool {
42    geom_type == "bezier" || geom_type == "spline"
43}
44
45/// The ids [`insert_anchor`] minted: the new on-curve `anchor` and the two off-curve
46/// handles flanking it (`before` precedes it in the polygon, `after` follows it). The
47/// caller decides what to hang on them — the tool draws each anchor's handle guides.
48#[derive(Clone, Debug)]
49pub struct InsertedAnchor {
50    /// The new on-curve anchor (`S`).
51    pub anchor: Value,
52    /// The handle immediately BEFORE the anchor (`D`) — it ends the first half-span.
53    pub before: Value,
54    /// The handle immediately AFTER the anchor (`E`) — it starts the second half-span.
55    pub after: Value,
56}
57
58/// Insert an anchor into the spline under the plane click `(u, v)`, or `None` when
59/// the click should mean something else. Shape-preserving: the curve through the
60/// grown control polygon is the curve that was there before.
61///
62/// The picking rules, in order:
63/// * A POINT within `radius` wins — the whole sketcher gives points priority over
64///   geometry, and a click on an anchor or handle would split at a span end anyway.
65/// * Otherwise the nearest SPLINE within `radius` is the target. Only splines are
66///   candidates, deliberately: the bezier tool hangs a dashed construction guide off
67///   each end handle, and on a shallow spline (near-collinear handles) those guides
68///   hug the curve along its whole length. Ranking all geometry together would let a
69///   spline's own guides shadow it and make the very splines a user most wants to
70///   refine the ones that cannot be refined.
71/// * The clicked span must have four DISTINCT ids and the click must land clear of
72///   the span's ends (`MIN_SPAN_PARAM`) — a cusp built by repeating an id cannot be
73///   subdivided without corrupting the other role, and a split at a span end is
74///   degenerate.
75///
76/// Does NOT solve, refresh or record undo — the engine wrapper owns that.
77pub fn insert_anchor(doc: &mut SketchDoc, u: f64, v: f64, radius: f64) -> Option<InsertedAnchor> {
78    // Points win the pick, exactly as they do for hover, select, drag and trim — the
79    // sketcher has ONE priority rule and a spline click does not get to break it.
80    if doc
81        .points
82        .iter()
83        .any(|p| (p.x - u).hypot(p.y - v) <= radius)
84    {
85        return None;
86    }
87
88    let (geo_id, span, t) = nearest_spline_span(doc, u, v, radius)?;
89    if !(MIN_SPAN_PARAM..=1.0 - MIN_SPAN_PARAM).contains(&t) {
90        return None;
91    }
92
93    // Resolve the clicked span's four ids + coordinates BEFORE any mutation: the
94    // splice below shifts every id after the split point.
95    let ids = {
96        let geo = doc.geometry(&geo_id)?;
97        let i0 = span * 3;
98        [
99            geo.points.get(i0)?.clone(),
100            geo.points.get(i0 + 1)?.clone(),
101            geo.points.get(i0 + 2)?.clone(),
102            geo.points.get(i0 + 3)?.clone(),
103        ]
104    };
105    let keys: Vec<String> = ids.iter().map(id_key).collect();
106    for i in 0..keys.len() {
107        for j in (i + 1)..keys.len() {
108            if keys[i] == keys[j] {
109                return None; // a repeated id plays two roles — subdividing would corrupt one.
110            }
111        }
112    }
113    let controls = span_controls(doc, &ids)?;
114
115    // de Casteljau at `t` — the two halves' control polygons.
116    let [p0, p1, p2, p3] = controls;
117    let a = lerp(p0, p1, t);
118    let b = lerp(p1, p2, t);
119    let c = lerp(p2, p3, t);
120    let d = lerp(a, b, t);
121    let e = lerp(b, c, t);
122    let s = lerp(d, e, t);
123
124    // The old handles slide inward onto the new half-spans' outer handles, keeping
125    // their ids (and everything constrained to them) attached to the same curve ends.
126    // Both resolved a moment ago through `span_controls`, so neither move can miss.
127    move_point(doc, &ids[1], a)?;
128    move_point(doc, &ids[2], c)?;
129    // Minted in polygon order so the ids read left-to-right along the curve.
130    let d_id = add_point(doc, d);
131    let s_id = add_point(doc, s);
132    let e_id = add_point(doc, e);
133
134    // `P0 A | P3` → `P0 A D S E C P3`: the three new ids go between the two reused
135    // handles, i.e. right after slot `i0 + 1`.
136    let geo = doc.geometry_mut(&geo_id)?;
137    let at = span * 3 + 2;
138    geo.points
139        .splice(at..at, [d_id.clone(), s_id.clone(), e_id.clone()]);
140
141    Some(InsertedAnchor { anchor: s_id, before: d_id, after: e_id })
142}
143
144/// The `(geometry id, span index, span parameter)` of the point nearest `(u, v)` over
145/// every spline in `doc`, or `None` when none passes within `radius`. Splines whose
146/// control count violates `3n + 1` are skipped — they are corrupt, not pickable.
147fn nearest_spline_span(doc: &SketchDoc, u: f64, v: f64, radius: f64) -> Option<(Value, usize, f64)> {
148    let mut best: Option<(f64, Value, usize, f64)> = None;
149    for geo in &doc.geometries {
150        if !is_spline_type(&geo.geom_type) {
151            continue;
152        }
153        let ids = &geo.points;
154        if ids.len() < 4 || (ids.len() - 1) % 3 != 0 {
155            continue;
156        }
157        for span in 0..(ids.len() - 1) / 3 {
158            let i0 = span * 3;
159            let Some(controls) = span_controls(
160                doc,
161                &[
162                    ids[i0].clone(),
163                    ids[i0 + 1].clone(),
164                    ids[i0 + 2].clone(),
165                    ids[i0 + 3].clone(),
166                ],
167            ) else {
168                continue;
169            };
170            let (dist, t) = closest_on_span(&controls, u, v);
171            if dist <= radius && best.as_ref().map_or(true, |(bd, ..)| dist < *bd) {
172                best = Some((dist, geo.id.clone(), span, t));
173            }
174        }
175    }
176    best.map(|(_, id, span, t)| (id, span, t))
177}
178
179/// The `(distance, parameter)` of the point on one cubic span nearest `(u, v)`.
180/// Sampled at [`SPAN_SAMPLES`] chords, then the click is projected onto the winning
181/// chord — the same sampled-polyline treatment trim gives a curve, and accurate to
182/// the chord's sagitta (well under a pixel at overlay resolution).
183fn closest_on_span(controls: &[[f64; 2]; 4], u: f64, v: f64) -> (f64, f64) {
184    let mut prev = eval_cubic(controls, 0.0);
185    let mut best = ((u - prev[0]).hypot(v - prev[1]), 0.0);
186    for i in 1..=SPAN_SAMPLES {
187        let t1 = i as f64 / SPAN_SAMPLES as f64;
188        let next = eval_cubic(controls, t1);
189        let (dx, dy) = (next[0] - prev[0], next[1] - prev[1]);
190        let len2 = (dx * dx + dy * dy).max(1e-24);
191        let s = (((u - prev[0]) * dx + (v - prev[1]) * dy) / len2).clamp(0.0, 1.0);
192        let dist = (u - (prev[0] + dx * s)).hypot(v - (prev[1] + dy * s));
193        if dist < best.0 {
194            let t0 = (i - 1) as f64 / SPAN_SAMPLES as f64;
195            best = (dist, t0 + (t1 - t0) * s);
196        }
197        prev = next;
198    }
199    best
200}
201
202/// Evaluate the cubic Bernstein sum over one span's four control points.
203fn eval_cubic(controls: &[[f64; 2]; 4], t: f64) -> [f64; 2] {
204    let mt = 1.0 - t;
205    let (w0, w1, w2, w3) = (
206        mt * mt * mt,
207        3.0 * mt * mt * t,
208        3.0 * mt * t * t,
209        t * t * t,
210    );
211    [
212        w0 * controls[0][0] + w1 * controls[1][0] + w2 * controls[2][0] + w3 * controls[3][0],
213        w0 * controls[0][1] + w1 * controls[1][1] + w2 * controls[2][1] + w3 * controls[3][1],
214    ]
215}
216
217/// Resolve four control-point ids to coordinates, or `None` if any is missing.
218fn span_controls(doc: &SketchDoc, ids: &[Value; 4]) -> Option<[[f64; 2]; 4]> {
219    let mut out = [[0.0; 2]; 4];
220    for (slot, id) in ids.iter().enumerate() {
221        let p = doc.point(id)?;
222        out[slot] = [p.x, p.y];
223    }
224    Some(out)
225}
226
227fn lerp(a: [f64; 2], b: [f64; 2], t: f64) -> [f64; 2] {
228    [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
229}
230
231/// Move an existing point to `(x, y)`, or `None` if it vanished.
232fn move_point(doc: &mut SketchDoc, id: &Value, at: [f64; 2]) -> Option<()> {
233    let p = doc.point_mut(id)?;
234    p.x = at[0];
235    p.y = at[1];
236    Some(())
237}
238
239/// Mint a fresh free point at `at` and return its id. Never snaps to a neighbour
240/// (unlike [`SketchDoc::snap_or_add_point`]): a subdivision's three new control points
241/// are new roles in the polygon even when they land on top of something.
242fn add_point(doc: &mut SketchDoc, at: [f64; 2]) -> Value {
243    let id = doc.next_point_id();
244    doc.points.push(SketchPoint {
245        id: id.clone(),
246        x: at[0],
247        y: at[1],
248        fixed: false,
249        construction: false,
250        external_reference: false,
251    });
252    id
253}
254
255/// If the point pair `(a, b)` is an adjacent (anchor, handle) pair of some spline
256/// in `doc` — an on-curve anchor at polygon slot `3i` next to one of its off-curve
257/// handles at `3i ± 1`, in either order — return it canonically as
258/// `(anchor, handle)`.
259///
260/// This is the shell's copy of the kernel solver's `spline_anchor_info` (the sketch
261/// module is deliberately kernel-free), and it has to stay the SAME predicate: the
262/// palette offers `⌒` for exactly the pairs the solver's tangent dispatch reads as a
263/// spline tangent, so an offered tangency is one that actually solves.
264pub fn anchor_handle_pair(doc: &SketchDoc, a: &Value, b: &Value) -> Option<(Value, Value)> {
265    anchor_handle_side(doc, a, b).map(|side| (side.anchor, side.handle))
266}
267
268/// One side of a spline joint as the palette sees it: which spline the
269/// `(anchor, handle)` pair belongs to and where that anchor sits in the control
270/// polygon. The shell's copy of the kernel's `spline_curvature_side`, minus the
271/// second control point — the palette only has to decide what to OFFER, and the
272/// solver derives the rest from the same polygon.
273#[derive(Clone, Debug)]
274pub struct GuideSide {
275    /// Id of the spline geometry.
276    pub geometry: Value,
277    /// Polygon slot `3i` of the anchor.
278    pub anchor_slot: usize,
279    /// Span count `n` of that spline.
280    pub seg_count: usize,
281    pub anchor: Value,
282    pub handle: Value,
283}
284
285impl GuideSide {
286    /// Whether the anchor is an INTERIOR anchor — the only place the solver's
287    /// implied `⏛` already holds both sides' tangents together.
288    pub fn interior(&self) -> bool {
289        self.anchor_slot > 0 && self.anchor_slot < 3 * self.seg_count
290    }
291}
292
293/// [`anchor_handle_pair`] with the spline and slot it was found on.
294pub fn anchor_handle_side(doc: &SketchDoc, a: &Value, b: &Value) -> Option<GuideSide> {
295    let (key_a, key_b) = (id_key(a), id_key(b));
296    for geo in &doc.geometries {
297        if !is_spline_type(&geo.geom_type) || geo.points.len() < 4 {
298            continue;
299        }
300        let seg_count = (geo.points.len() - 1) / 3;
301        for i in 0..=seg_count {
302            let anchor_slot = 3 * i;
303            let anchor_key = id_key(&geo.points[anchor_slot]);
304            if anchor_key != key_a && anchor_key != key_b {
305                continue;
306            }
307            let before = (i > 0).then(|| anchor_slot - 1);
308            let after = (i < seg_count).then_some(anchor_slot + 1);
309            for slot in [before, after].into_iter().flatten() {
310                let handle_key = id_key(&geo.points[slot]);
311                let (anchor, handle) = if anchor_key == key_a && handle_key == key_b {
312                    (a.clone(), b.clone())
313                } else if anchor_key == key_b && handle_key == key_a {
314                    (b.clone(), a.clone())
315                } else {
316                    continue;
317                };
318                return Some(GuideSide {
319                    geometry: geo.id.clone(),
320                    anchor_slot,
321                    seg_count,
322                    anchor,
323                    handle,
324                });
325            }
326        }
327    }
328    None
329}
330
331/// The `(anchor, handle)` side a HANDLE GUIDE stands for, when it is one.
332pub fn guide_side(doc: &SketchDoc, geo: &SketchGeometry) -> Option<GuideSide> {
333    if geo.geom_type != "line" || geo.points.len() < 2 {
334        return None;
335    }
336    anchor_handle_side(doc, &geo.points[0], &geo.points[1])
337}
338
339/// Whether two selected geometries are the two sides of ONE curvature joint the
340/// solver can hold curvature continuous — the pairs the `ϰ` palette offer is
341/// gated on:
342///
343/// * the two handle guides flanking one INTERIOR anchor of a spline, where the
344///   implied `⏛` already provides the tangency, or
345/// * two spline END guides whose anchors are joined — the same point, or held
346///   together by a coincident — including one spline closed on itself.
347///
348/// Two ends that merely exist are NOT a joint: with nothing holding the anchors
349/// together the constraint would state curvature continuity across a gap.
350pub fn is_curvature_joint(doc: &SketchDoc, a: &SketchGeometry, b: &SketchGeometry) -> bool {
351    let (Some(side_a), Some(side_b)) = (guide_side(doc, a), guide_side(doc, b)) else {
352        return false;
353    };
354    if id_key(&side_a.handle) == id_key(&side_b.handle) {
355        return false;
356    }
357    let same_anchor = id_key(&side_a.anchor) == id_key(&side_b.anchor);
358    if id_key(&side_a.geometry) == id_key(&side_b.geometry)
359        && side_a.anchor_slot == side_b.anchor_slot
360        && side_a.interior()
361        && same_anchor
362    {
363        return true;
364    }
365    if side_a.interior() || side_b.interior() {
366        return false;
367    }
368    same_anchor || anchors_coincident(doc, &side_a.anchor, &side_b.anchor)
369}
370
371/// Whether a coincident constraint joins the two anchors.
372fn anchors_coincident(doc: &SketchDoc, a: &Value, b: &Value) -> bool {
373    let (key_a, key_b) = (id_key(a), id_key(b));
374    doc.constraints.iter().any(|c| {
375        if c.ctype() != Some("≡") {
376            return false;
377        }
378        let keys: Vec<String> = c.points().iter().map(id_key).collect();
379        keys.contains(&key_a) && keys.contains(&key_b)
380    })
381}
382
383/// Whether `geo` is a spline HANDLE GUIDE: the dashed construction line the bezier
384/// tool hangs from an anchor to each neighbouring handle (at a drawn end and at an
385/// inserted interior anchor alike). Selecting one is how a user grabs the spline's
386/// tangent direction there, so the constraint palette reads such a line as the
387/// spline — the same reading the solver's tangent dispatch already makes.
388pub fn is_handle_guide(doc: &SketchDoc, geo: &SketchGeometry) -> bool {
389    guide_side(doc, geo).is_some()
390}
391
392/// Whether `geo` is a handle guide at a spline END anchor — where a curvature
393/// constraint against a circle or arc is offered. The solver accepts the same
394/// residual at an interior anchor (that side's curvature takes the circle's),
395/// but an end is where continuing a curve into an arc is the question being
396/// asked, and it is the case the plan derives.
397pub fn is_end_handle_guide(doc: &SketchDoc, geo: &SketchGeometry) -> bool {
398    guide_side(doc, geo).is_some_and(|side| !side.interior())
399}
400
401// ---------------------------------------------------------------------------
402// Test-only geometry readers.
403//
404// Shared by this module's unit tests AND the engine-level tool tests
405// (`engine_state::sketch_mode_tests`), so "the curve did not move" is measured by
406// ONE implementation in both places — analytically, against the control polygons,
407// never through a sampled polyline (whose chord gaps run to ~1e-2 at any sane sample
408// count and would swallow the deviation these tests exist to catch).
409// ---------------------------------------------------------------------------
410
411
412
413
414
415