Skip to main content

brep_kernel/geometry/
control_cage.rs

1//! The CONTROL CAGE of a NURBS solid (`feature_pipeline::features::nurbs_solid`).
2//!
3//! A cage is a lattice of control points wrapped around a primitive: `M`
4//! RINGS of `N` points each, closed around the body's axis (+Y, the polar
5//! axis every other primitive uses), plus one POLE point at either end. The body is the bicubic B-spline surface those points
6//! control — the smooth "subdivision" of the cage, lying inside it the way a
7//! subdivision surface lies inside its control mesh — closed into a solid the
8//! way the kernel's sphere is: one face, one seam edge, two degenerate pole
9//! edges. Every point is free: dragging it deforms the body smoothly, and new
10//! LOOPS of points (a ring, or a column running pole to pole) are inserted by
11//! knot insertion, which refines the cage WITHOUT changing the body — the same
12//! surface, with more handles on it.
13//!
14//! # Parameterisation
15//!
16//! The around direction `u` is PERIODIC: a ring stores `N` distinct points
17//! and `N + 1` cumulative knots `t_0 = 0 < … < t_N = period`, extended
18//! cyclically (`t_{i+N} = t_i + period`). Point `c` of a ring carries the
19//! basis supported on `[t_c, t_{c+4}]`, so the curve over span `[t_k, t_{k+1}]`
20//! blends points `k-3 … k` (cyclic). The kernel's surfaces need CLAMPED knot
21//! vectors, so [`ControlCage::surface`] converts each row by unwrapping the
22//! periodic sequence and inserting the seam knot to full multiplicity (Boehm),
23//! which keeps the surface identical and C² across the seam — a deformed cage
24//! shows no crease where the seam edge runs.
25//!
26//! The pole-to-pole direction `v` is OPEN and clamped over `M + 2` rows: the
27//! bottom pole, the rings bottom-up, the top pole. A pole row is `N` copies of
28//! the pole point, so the surface passes through the pole exactly and the two
29//! pole edges are degenerate, exactly as on the sphere.
30//!
31//! # Point indexing
32//!
33//! Every editor and script addresses a point by ONE linear index:
34//! `0` is the bottom pole, `1 + r·N + c` is column `c` of ring `r` (ring `0`
35//! nearest the bottom pole), and `1 + M·N` is the top pole. The wrap column
36//! the clamped surface carries and the collapsed pole rows are never exposed —
37//! they are derived here, once.
38
39use crate::curve::parameter_line;
40use crate::topology::{
41    BrepSolid, CoedgeRecord, EdgeRecord, FaceRecord, LoopRecord, ShellRecord, VertexRecord,
42};
43use crate::{make_line, NurbsSurface, Vec3, Vec4};
44use serde_json::Value;
45
46/// The B-spline degree in both directions: bicubic, the subdivision-surface
47/// smoothness (C² everywhere a knot is simple).
48pub const CAGE_DEGREE: usize = 3;
49/// The fewest points a ring may have: a cubic periodic ring needs four.
50pub const MIN_SEGMENTS: usize = 4;
51/// The fewest rings: with the two poles that is `degree + 1` rows, one span.
52pub const MIN_RINGS: usize = 2;
53/// Caps on the initial cage so a typo cannot ask for a million handles.
54pub const MAX_SEGMENTS: usize = 64;
55pub const MAX_RINGS: usize = 64;
56
57/// The primitive the initial cage is wrapped around.
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub enum CagePrimitive {
60    /// Rings at even latitudes of the ellipsoid inscribed in the size box.
61    Sphere,
62    /// Elliptical rings (in XZ) at even heights along Y, the end rings in
63    /// the end planes.
64    Cylinder,
65    /// Rectangular rings (in XZ) at even heights along Y, corners first.
66    Box,
67}
68
69impl CagePrimitive {
70    /// The schema's option names (case-insensitive on read).
71    pub const OPTIONS: [&'static str; 3] = ["BOX", "CYLINDER", "SPHERE"];
72
73    pub fn parse(name: &str) -> Option<Self> {
74        match name.trim().to_ascii_uppercase().as_str() {
75            "SPHERE" | "ELLIPSOID" => Some(Self::Sphere),
76            "CYLINDER" => Some(Self::Cylinder),
77            "BOX" | "CUBE" => Some(Self::Box),
78            _ => None,
79        }
80    }
81
82    pub fn name(self) -> &'static str {
83        match self {
84            Self::Sphere => "SPHERE",
85            Self::Cylinder => "CYLINDER",
86            Self::Box => "BOX",
87        }
88    }
89}
90
91/// Where a linear point index lands in the cage.
92#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93pub enum CagePoint {
94    /// The bottom (`0`) or top (`1`) pole.
95    Pole(usize),
96    /// Column `column` of ring `ring` (ring `0` nearest the bottom pole).
97    Ring { ring: usize, column: usize },
98}
99
100/// The cage: see the module docs for the layout.
101#[derive(Clone, Debug)]
102pub struct ControlCage {
103    /// `N + 1` cumulative periodic knots, `knots_u[0] = 0`, `knots_u[N]` the period.
104    pub knots_u: Vec<f64>,
105    /// Clamped cubic knots over the `M + 2` rows: `M + 6` values.
106    pub knots_v: Vec<f64>,
107    /// The bottom and top pole points.
108    pub poles: [Vec3; 2],
109    /// `M` rings of `N` points, bottom-up.
110    pub rings: Vec<Vec<Vec3>>,
111}
112
113/// Knots closer than this fraction of the period are one knot.
114const KNOT_TOL: f64 = 1e-9;
115
116impl ControlCage {
117    // --- construction --------------------------------------------------------
118
119    /// The initial cage around a primitive filling the `size` box (centred on
120    /// the origin, poles on the Y axis), with `segments` points per ring and
121    /// `rings` rings. Rings run counter-clockwise about +Y seen from above
122    /// (from +X towards −Z), so `Su × Sv` points outward.
123    pub fn primitive(
124        kind: CagePrimitive,
125        size: [f64; 3],
126        segments: usize,
127        rings: usize,
128    ) -> Result<Self, String> {
129        if !(MIN_SEGMENTS..=MAX_SEGMENTS).contains(&segments) {
130            return Err(format!(
131                "control cage: segments must be between {MIN_SEGMENTS} and {MAX_SEGMENTS}, got {segments}"
132            ));
133        }
134        if !(MIN_RINGS..=MAX_RINGS).contains(&rings) {
135            return Err(format!(
136                "control cage: rings must be between {MIN_RINGS} and {MAX_RINGS}, got {rings}"
137            ));
138        }
139        let half = [size[0].abs() * 0.5, size[1].abs() * 0.5, size[2].abs() * 0.5];
140        if half.iter().any(|h| !h.is_finite() || *h <= 1e-9) {
141            return Err(format!("control cage: every size must be positive, got {size:?}"));
142        }
143        let n = segments;
144        let m = rings;
145        let mut out = Vec::with_capacity(m);
146        for r in 0..m {
147            // Rings are spaced so that ring 0 and ring M-1 are the END rings
148            // of a cylinder / box (in the end planes), and even latitudes
149            // (poles excluded) on the sphere.
150            let fraction = if m == 1 { 0.5 } else { r as f64 / (m - 1) as f64 };
151            let ring: Vec<Vec3> = (0..n)
152                .map(|c| {
153                    let phi = std::f64::consts::TAU * c as f64 / n as f64;
154                    let height = -half[1] + 2.0 * half[1] * fraction;
155                    match kind {
156                        CagePrimitive::Sphere => {
157                            let theta = std::f64::consts::PI * (r + 1) as f64 / (m + 1) as f64;
158                            Vec3::new(
159                                half[0] * theta.sin() * phi.cos(),
160                                -half[1] * theta.cos(),
161                                -half[2] * theta.sin() * phi.sin(),
162                            )
163                        }
164                        CagePrimitive::Cylinder => {
165                            Vec3::new(half[0] * phi.cos(), height, -half[2] * phi.sin())
166                        }
167                        CagePrimitive::Box => {
168                            // Corners first: the first point sits on the +X−Z
169                            // corner, so 4 segments give the four corners and 8
170                            // add the edge midpoints.
171                            let angle = phi + std::f64::consts::FRAC_PI_4;
172                            let (s, c) = angle.sin_cos();
173                            let scale = 1.0 / c.abs().max(s.abs());
174                            Vec3::new(half[0] * c * scale, height, -half[2] * s * scale)
175                        }
176                    }
177                })
178                .collect();
179            out.push(ring);
180        }
181        let poles = [Vec3::new(0.0, -half[1], 0.0), Vec3::new(0.0, half[1], 0.0)];
182        let knots_u = (0..=n).map(|i| i as f64 / n as f64).collect();
183        let knots_v = crate::uniform_clamped_knots(m + 2, CAGE_DEGREE)?;
184        let cage = Self {
185            knots_u,
186            knots_v,
187            poles,
188            rings: out,
189        };
190        cage.validate()?;
191        Ok(cage)
192    }
193
194    /// Every structural invariant: ring sizes, knot counts, monotone knots,
195    /// finite points. A persisted cage is checked through this before use.
196    pub fn validate(&self) -> Result<(), String> {
197        let n = self.segments();
198        let m = self.rings.len();
199        if n < MIN_SEGMENTS {
200            return Err(format!("control cage: a ring needs at least {MIN_SEGMENTS} points, got {n}"));
201        }
202        if m < MIN_RINGS {
203            return Err(format!("control cage: needs at least {MIN_RINGS} rings, got {m}"));
204        }
205        if self.rings.iter().any(|ring| ring.len() != n) {
206            return Err("control cage: every ring must have the same number of points".into());
207        }
208        if self.knots_u.len() != n + 1 {
209            return Err(format!(
210                "control cage: expected {} periodic knots for {n} segments, got {}",
211                n + 1,
212                self.knots_u.len()
213            ));
214        }
215        if self.knots_v.len() != m + 2 + CAGE_DEGREE + 1 {
216            return Err(format!(
217                "control cage: expected {} clamped knots for {m} rings, got {}",
218                m + 2 + CAGE_DEGREE + 1,
219                self.knots_v.len()
220            ));
221        }
222        let finite = |knots: &[f64]| knots.iter().all(|k| k.is_finite());
223        if !finite(&self.knots_u) || !finite(&self.knots_v) {
224            return Err("control cage: knots must be finite".into());
225        }
226        if self.knots_u[0].abs() > KNOT_TOL || self.period() <= KNOT_TOL {
227            return Err("control cage: periodic knots must start at 0 and span a positive period".into());
228        }
229        if self.knots_u.windows(2).any(|w| w[1] <= w[0] + KNOT_TOL * self.period()) {
230            return Err("control cage: periodic knots must be strictly increasing".into());
231        }
232        if self.knots_v.windows(2).any(|w| w[1] < w[0] - KNOT_TOL) {
233            return Err("control cage: ring knots must be non-decreasing".into());
234        }
235        let [v0, v1] = self.domain_v();
236        if v1 - v0 <= KNOT_TOL {
237            return Err("control cage: the ring knots span no interval".into());
238        }
239        for index in 0..=CAGE_DEGREE {
240            let last = self.knots_v.len() - 1 - index;
241            if (self.knots_v[index] - self.knots_v[0]).abs() > KNOT_TOL
242                || (self.knots_v[last] - self.knots_v[self.knots_v.len() - 1]).abs() > KNOT_TOL
243            {
244                return Err("control cage: ring knots must be clamped".into());
245            }
246        }
247        // A run of more than `degree` equal interior knots breaks the basis.
248        let mut run = 1;
249        for w in self.knots_v[CAGE_DEGREE + 1..self.knots_v.len() - CAGE_DEGREE - 1].windows(2) {
250            run = if (w[1] - w[0]).abs() <= KNOT_TOL { run + 1 } else { 1 };
251            if run > CAGE_DEGREE {
252                return Err("control cage: an interior ring knot repeats more than the degree".into());
253            }
254        }
255        let finite_point = |p: &Vec3| p.x.is_finite() && p.y.is_finite() && p.z.is_finite();
256        if !self.poles.iter().all(finite_point) || !self.rings.iter().flatten().all(finite_point) {
257            return Err("control cage: every point must be finite".into());
258        }
259        Ok(())
260    }
261
262    // --- shape -----------------------------------------------------------------
263
264    /// Points per ring (`N`).
265    pub fn segments(&self) -> usize {
266        self.rings.first().map_or(0, Vec::len)
267    }
268
269    /// Rings (`M`).
270    pub fn ring_count(&self) -> usize {
271        self.rings.len()
272    }
273
274    /// Every free point: the rings' plus the two poles.
275    pub fn point_count(&self) -> usize {
276        self.rings.len() * self.segments() + 2
277    }
278
279    /// The period of the around direction.
280    pub fn period(&self) -> f64 {
281        self.knots_u[self.knots_u.len() - 1]
282    }
283
284    /// The clamped `v` domain.
285    pub fn domain_v(&self) -> [f64; 2] {
286        [self.knots_v[CAGE_DEGREE], self.knots_v[self.knots_v.len() - 1 - CAGE_DEGREE]]
287    }
288
289    /// Where linear index `index` lands.
290    pub fn locate(&self, index: usize) -> Option<CagePoint> {
291        let n = self.segments();
292        let m = self.rings.len();
293        if index == 0 {
294            return Some(CagePoint::Pole(0));
295        }
296        if index == 1 + m * n {
297            return Some(CagePoint::Pole(1));
298        }
299        if index > 1 + m * n {
300            return None;
301        }
302        let offset = index - 1;
303        Some(CagePoint::Ring {
304            ring: offset / n,
305            column: offset % n,
306        })
307    }
308
309    /// The linear index of column `column` of ring `ring`.
310    pub fn index_of(&self, ring: usize, column: usize) -> usize {
311        1 + ring * self.segments() + column
312    }
313
314    /// The linear index of pole `which` (0 bottom, 1 top).
315    pub fn pole_index(&self, which: usize) -> usize {
316        if which == 0 {
317            0
318        } else {
319            1 + self.rings.len() * self.segments()
320        }
321    }
322
323    pub fn point(&self, index: usize) -> Option<Vec3> {
324        match self.locate(index)? {
325            CagePoint::Pole(which) => Some(self.poles[which]),
326            CagePoint::Ring { ring, column } => Some(self.rings[ring][column]),
327        }
328    }
329
330    pub fn set_point(&mut self, index: usize, point: Vec3) -> Result<(), String> {
331        match self
332            .locate(index)
333            .ok_or_else(|| format!("control cage: no point {index}"))?
334        {
335            CagePoint::Pole(which) => self.poles[which] = point,
336            CagePoint::Ring { ring, column } => self.rings[ring][column] = point,
337        }
338        Ok(())
339    }
340
341    /// Every point in linear order.
342    pub fn points(&self) -> Vec<Vec3> {
343        let mut out = Vec::with_capacity(self.point_count());
344        out.push(self.poles[0]);
345        for ring in &self.rings {
346            out.extend_from_slice(ring);
347        }
348        out.push(self.poles[1]);
349        out
350    }
351
352    /// The `M + 2` rows the surface is built from: a pole row is `N` copies of
353    /// its pole.
354    fn rows(&self) -> Vec<Vec<Vec3>> {
355        let n = self.segments();
356        let mut rows = Vec::with_capacity(self.rings.len() + 2);
357        rows.push(vec![self.poles[0]; n]);
358        rows.extend(self.rings.iter().cloned());
359        rows.push(vec![self.poles[1]; n]);
360        rows
361    }
362
363    /// The cyclic periodic knot `t_i` for any integer `i`.
364    fn knot_u(&self, i: isize) -> f64 {
365        let n = self.segments() as isize;
366        let q = i.div_euclid(n);
367        let r = i.rem_euclid(n) as usize;
368        self.knots_u[r] + q as f64 * self.period()
369    }
370
371    /// The Greville abscissa of column `column` — the `u` the point "sits" at.
372    pub fn greville_u(&self, column: usize) -> f64 {
373        let c = column as isize;
374        (self.knot_u(c + 1) + self.knot_u(c + 2) + self.knot_u(c + 3)) / 3.0
375    }
376
377    /// The Greville abscissa of row `row` (`0` the bottom pole, `M + 1` the top).
378    pub fn greville_v(&self, row: usize) -> f64 {
379        (self.knots_v[row + 1] + self.knots_v[row + 2] + self.knots_v[row + 3]) / 3.0
380    }
381
382    // --- loop insertion --------------------------------------------------------
383
384    /// Insert a ring of points between rows `row` and `row + 1` (`row` in
385    /// `0 ..= M`, row `0` being the bottom pole), by inserting the `v` knot
386    /// half-way between their Greville abscissae. The surface is unchanged.
387    /// Returns the index (`0`-based, among the rings) of the new ring.
388    pub fn insert_ring_between(&mut self, row: usize) -> Result<usize, String> {
389        let m = self.rings.len();
390        if row > m {
391            return Err(format!("control cage: no row pair {row}/{} to insert between", row + 1));
392        }
393        let v = 0.5 * (self.greville_v(row) + self.greville_v(row + 1));
394        self.insert_ring_at(v)
395    }
396
397    /// Insert a ring at parameter `v` (strictly inside the `v` domain).
398    pub fn insert_ring_at(&mut self, v: f64) -> Result<usize, String> {
399        let [v0, v1] = self.domain_v();
400        if !(v > v0 + KNOT_TOL && v < v1 - KNOT_TOL) {
401            return Err(format!("control cage: ring parameter {v} is outside ({v0}, {v1})"));
402        }
403        let n = self.segments();
404        let rows = self.rows();
405        let mut new_rows: Vec<Vec<Vec3>> = Vec::new();
406        let mut new_knots = self.knots_v.clone();
407        for column in 0..n {
408            let column_points: Vec<Vec3> = rows.iter().map(|row| row[column]).collect();
409            let (points, knots) = insert_knot_open(&column_points, &self.knots_v, CAGE_DEGREE, v)?;
410            if new_rows.is_empty() {
411                new_rows = vec![Vec::with_capacity(n); points.len()];
412                new_knots = knots;
413            }
414            for (row, point) in new_rows.iter_mut().zip(points) {
415                row.push(point);
416            }
417        }
418        // Row 0 and the last row are the untouched poles (Boehm never blends
419        // the first or last control point); every row between is a ring.
420        let count = new_rows.len();
421        self.rings = new_rows[1..count - 1].to_vec();
422        self.knots_v = new_knots;
423        // The new handle: the ring whose Greville abscissa is nearest `v`.
424        let nearest = (0..self.rings.len())
425            .min_by(|a, b| {
426                (self.greville_v(a + 1) - v)
427                    .abs()
428                    .total_cmp(&(self.greville_v(b + 1) - v).abs())
429            })
430            .unwrap_or(0);
431        self.validate()?;
432        Ok(nearest)
433    }
434
435    /// Insert a column of points (pole to pole) between columns `column` and
436    /// `column + 1` (cyclic), half-way between their Greville abscissae. The
437    /// surface is unchanged. Returns the new column's index.
438    pub fn insert_column_between(&mut self, column: usize) -> Result<usize, String> {
439        let n = self.segments();
440        if column >= n {
441            return Err(format!("control cage: no column {column}"));
442        }
443        let c = column as isize;
444        let g0 = (self.knot_u(c + 1) + self.knot_u(c + 2) + self.knot_u(c + 3)) / 3.0;
445        let g1 = (self.knot_u(c + 2) + self.knot_u(c + 3) + self.knot_u(c + 4)) / 3.0;
446        let u = 0.5 * (g0 + g1);
447        self.insert_column_at(u.rem_euclid(self.period()))
448    }
449
450    /// Insert a column at periodic parameter `u` (reduced into `[0, period)`).
451    pub fn insert_column_at(&mut self, u: f64) -> Result<usize, String> {
452        let n = self.segments();
453        let period = self.period();
454        let mut u = u.rem_euclid(period);
455        // Snap away from an existing knot: a repeated knot would reduce the
456        // continuity there, and the new column would sit ON an old one.
457        if let Some(k) = self
458            .knots_u
459            .iter()
460            .position(|t| (t - u).abs() <= KNOT_TOL * period)
461        {
462            let next = self.knot_u(k as isize + 1);
463            u = 0.5 * (self.knots_u[k] + next);
464            if u >= period {
465                u -= period;
466            }
467        }
468        // The span [t_k, t_{k+1}) holding u.
469        let k = (0..n)
470            .find(|&k| self.knots_u[k] <= u && u < self.knots_u[k + 1])
471            .ok_or_else(|| format!("control cage: column parameter {u} is outside the period"))?
472            as isize;
473        let new_len = n + 1;
474        let mut new_rings = Vec::with_capacity(self.rings.len());
475        for ring in &self.rings {
476            let p = |i: isize| ring[i.rem_euclid(n as isize) as usize];
477            let mut out = vec![Vec3::default(); new_len];
478            for j in (k - 2)..=(k - 2 + n as isize) {
479                let value = if j <= k {
480                    let t_j = self.knot_u(j);
481                    let alpha = (u - t_j) / (self.knot_u(j + 3) - t_j);
482                    p(j).scale(alpha).add(p(j - 1).scale(1.0 - alpha))
483                } else {
484                    p(j - 1)
485                };
486                out[j.rem_euclid(new_len as isize) as usize] = value;
487            }
488            new_rings.push(out);
489        }
490        let ku = k as usize;
491        let mut knots = Vec::with_capacity(n + 2);
492        knots.extend_from_slice(&self.knots_u[..=ku]);
493        knots.push(u);
494        knots.extend_from_slice(&self.knots_u[ku + 1..]);
495        self.rings = new_rings;
496        self.knots_u = knots;
497        self.validate()?;
498        // The new handle: the column whose Greville abscissa is nearest `u`.
499        let nearest = (0..new_len)
500            .min_by(|&a, &b| {
501                let da = cyclic_distance(self.greville_u(a), u, period);
502                let db = cyclic_distance(self.greville_u(b), u, period);
503                da.total_cmp(&db)
504            })
505            .unwrap_or(0);
506        Ok(nearest)
507    }
508
509    // --- the surface and the solid --------------------------------------------
510
511    /// One row of the cage as a CLAMPED cubic B-spline around the seam:
512    /// `N + 3` points and `N + 7` knots over `[0, period]`, the same curve
513    /// as the periodic one (see the module docs).
514    fn clamped_row(&self, row: &[Vec3]) -> Result<(Vec<Vec3>, Vec<f64>), String> {
515        let n = self.segments();
516        let p = CAGE_DEGREE;
517        // Unwrap: point j carries P_{(j - p) mod N}, knot j is t_{j - p}.
518        let points: Vec<Vec3> = (0..n + p).map(|j| row[(j + n - p) % n]).collect();
519        let knots: Vec<f64> = (0..n + 2 * p + 1)
520            .map(|j| self.knot_u(j as isize - p as isize))
521            .collect();
522        let start = self.knot_u(0);
523        let end = self.knot_u(n as isize);
524        let (points, knots) = insert_knot_open(&points, &knots, p, start)?;
525        let (points, knots) = insert_knot_open(&points, &knots, p, start)?;
526        let (points, knots) = insert_knot_open(&points, &knots, p, end)?;
527        let (mut points, mut knots) = insert_knot_open(&points, &knots, p, end)?;
528        // The p - 1 points and knots outside the domain on either side carry
529        // nothing of the curve on it; the outermost surviving knot is then the
530        // clamp knot by value.
531        let drop = p - 1;
532        points.drain(..drop);
533        points.truncate(points.len() - drop);
534        knots.drain(..drop);
535        knots.truncate(knots.len() - drop);
536        knots[0] = start;
537        let last = knots.len() - 1;
538        knots[last] = end;
539        debug_assert_eq!(points.len(), n + p);
540        debug_assert_eq!(knots.len(), n + 2 * p + 1);
541        Ok((points, knots))
542    }
543
544    /// The bicubic surface the cage controls, over `[0, 1] × [0, 1]`: `u`
545    /// around (clamped at the seam), `v` from the bottom pole to the top.
546    pub fn surface(&self) -> Result<NurbsSurface, String> {
547        self.validate()?;
548        let n = self.segments();
549        let rows = self.rows();
550        let mut knots_u: Option<Vec<f64>> = None;
551        let mut columns: Vec<Vec<Vec4>> = vec![Vec::with_capacity(rows.len()); n + CAGE_DEGREE];
552        for row in &rows {
553            let (points, knots) = self.clamped_row(row)?;
554            if knots_u.is_none() {
555                let period = self.period();
556                knots_u = Some(knots.iter().map(|k| (k / period).clamp(0.0, 1.0)).collect());
557            }
558            for (column, point) in columns.iter_mut().zip(points) {
559                column.push(Vec4::from_point(point, 1.0));
560            }
561        }
562        let [v0, v1] = self.domain_v();
563        let knots_v: Vec<f64> = self
564            .knots_v
565            .iter()
566            .map(|k| ((k - v0) / (v1 - v0)).clamp(0.0, 1.0))
567            .collect();
568        NurbsSurface::new(
569            CAGE_DEGREE,
570            CAGE_DEGREE,
571            knots_u.expect("at least four rows"),
572            knots_v,
573            columns,
574        )
575    }
576
577    /// The closed solid: one face carrying `face_name`, a seam edge at `u = 0`
578    /// and a degenerate edge at each pole — the sphere's topology on this
579    /// surface. Validated before it is returned.
580    pub fn solid(&self, face_name: Option<&str>) -> Result<BrepSolid, String> {
581        let surface = self.surface()?;
582        let south = surface.evaluate(0.0, 0.0)?;
583        let north = surface.evaluate(0.0, 1.0)?;
584        let seam = surface.iso_curve_u(0.0)?;
585        let solid = BrepSolid {
586            mass_properties_cache: Default::default(),
587            cells: Default::default(),
588            id: 120,
589            vertices: vec![
590                VertexRecord { id: 1, point: south },
591                VertexRecord { id: 2, point: north },
592            ],
593            edges: vec![
594                EdgeRecord {
595                    id: 10,
596                    curve: seam,
597                    t0: 0.0,
598                    t1: 1.0,
599                    start_vertex_id: 1,
600                    end_vertex_id: 2,
601                    degenerate: false,
602                    name: None,
603                },
604                EdgeRecord {
605                    id: 11,
606                    curve: make_line(south, south)?,
607                    t0: 0.0,
608                    t1: 1.0,
609                    start_vertex_id: 1,
610                    end_vertex_id: 1,
611                    degenerate: true,
612                    name: None,
613                },
614                EdgeRecord {
615                    id: 12,
616                    curve: make_line(north, north)?,
617                    t0: 0.0,
618                    t1: 1.0,
619                    start_vertex_id: 2,
620                    end_vertex_id: 2,
621                    degenerate: true,
622                    name: None,
623                },
624            ],
625            shells: vec![ShellRecord {
626                id: 119,
627                faces: vec![FaceRecord {
628                    id: 118,
629                    surface,
630                    same_sense: true,
631                    loops: vec![LoopRecord {
632                        id: 117,
633                        coedges: vec![
634                            CoedgeRecord {
635                                id: 101,
636                                edge_id: 11,
637                                forward: true,
638                                pcurve: parameter_line(0.0, 0.0, 1.0, 0.0)?,
639                            },
640                            CoedgeRecord {
641                                id: 102,
642                                edge_id: 10,
643                                forward: true,
644                                pcurve: parameter_line(1.0, 0.0, 1.0, 1.0)?,
645                            },
646                            CoedgeRecord {
647                                id: 103,
648                                edge_id: 12,
649                                forward: true,
650                                pcurve: parameter_line(1.0, 1.0, 0.0, 1.0)?,
651                            },
652                            CoedgeRecord {
653                                id: 104,
654                                edge_id: 10,
655                                forward: false,
656                                pcurve: parameter_line(0.0, 1.0, 0.0, 0.0)?,
657                            },
658                        ],
659                    }],
660                    name: face_name.map(str::to_string),
661                }],
662            }],
663            genus: 0,
664            kind: crate::BodyKind::Solid,
665        };
666        let issues = solid.validate();
667        if !issues.is_empty() {
668            return Err(format!("control cage solid has invalid topology: {issues:?}"));
669        }
670        Ok(solid)
671    }
672
673    // --- persistence -----------------------------------------------------------
674
675    /// The persisted shape: `{ knotsU, knotsV, poles: [[x,y,z],[x,y,z]],
676    /// rings: [[[x,y,z], …], …] }`.
677    pub fn to_json(&self) -> Value {
678        let point = |p: &Vec3| serde_json::json!([p.x, p.y, p.z]);
679        serde_json::json!({
680            "knotsU": self.knots_u,
681            "knotsV": self.knots_v,
682            "poles": [point(&self.poles[0]), point(&self.poles[1])],
683            "rings": self.rings.iter().map(|ring| ring.iter().map(point).collect::<Vec<_>>()).collect::<Vec<_>>(),
684        })
685    }
686
687    /// Read a persisted cage, validating it.
688    pub fn from_json(value: &Value) -> Result<Self, String> {
689        let numbers = |key: &str| -> Result<Vec<f64>, String> {
690            value
691                .get(key)
692                .and_then(Value::as_array)
693                .ok_or_else(|| format!("control cage: `{key}` must be an array"))?
694                .iter()
695                .map(|n| n.as_f64().ok_or_else(|| format!("control cage: `{key}` holds a non-number")))
696                .collect()
697        };
698        let point = |p: &Value, what: &str| -> Result<Vec3, String> {
699            let xyz = p
700                .as_array()
701                .filter(|a| a.len() == 3)
702                .ok_or_else(|| format!("control cage: {what} must be [x, y, z]"))?;
703            let read = |i: usize| xyz[i].as_f64().ok_or_else(|| format!("control cage: {what} holds a non-number"));
704            Ok(Vec3::new(read(0)?, read(1)?, read(2)?))
705        };
706        let poles = value
707            .get("poles")
708            .and_then(Value::as_array)
709            .filter(|a| a.len() == 2)
710            .ok_or("control cage: `poles` must hold two points")?;
711        let rings = value
712            .get("rings")
713            .and_then(Value::as_array)
714            .ok_or("control cage: `rings` must be an array")?
715            .iter()
716            .map(|ring| {
717                ring.as_array()
718                    .ok_or_else(|| "control cage: a ring must be an array".to_string())?
719                    .iter()
720                    .map(|p| point(p, "a ring point"))
721                    .collect::<Result<Vec<_>, _>>()
722            })
723            .collect::<Result<Vec<_>, _>>()?;
724        let cage = Self {
725            knots_u: numbers("knotsU")?,
726            knots_v: numbers("knotsV")?,
727            poles: [point(&poles[0], "a pole")?, point(&poles[1], "a pole")?],
728            rings,
729        };
730        cage.validate()?;
731        Ok(cage)
732    }
733}
734
735/// Boehm knot insertion on an OPEN B-spline (clamped or not): insert `u` once
736/// into `knots`, returning the refined points and knots. `u` must lie in the
737/// domain `[knots[p], knots[n]]`; an existing multiplicity up to `p` is fine
738/// (the affected blends then degenerate to copies, as the algorithm says).
739fn insert_knot_open(
740    points: &[Vec3],
741    knots: &[f64],
742    degree: usize,
743    u: f64,
744) -> Result<(Vec<Vec3>, Vec<f64>), String> {
745    let n = points.len();
746    let p = degree;
747    if knots.len() != n + p + 1 {
748        return Err(format!(
749            "insert_knot: {} knots do not fit {n} points of degree {p}",
750            knots.len()
751        ));
752    }
753    let [start, end] = [knots[p], knots[n]];
754    if u < start - KNOT_TOL || u > end + KNOT_TOL {
755        return Err(format!("insert_knot: {u} is outside the domain [{start}, {end}]"));
756    }
757    // The span: the largest k in [p, n - 1] with knots[k] <= u.
758    let mut k = p;
759    for (i, pair) in knots.windows(2).enumerate().take(n).skip(p) {
760        if pair[0] <= u {
761            k = i;
762        }
763    }
764    let mut out = Vec::with_capacity(n + 1);
765    for i in 0..=n {
766        let value = if i + p <= k {
767            points[i]
768        } else if i <= k {
769            let denominator = knots[i + p] - knots[i];
770            let alpha = if denominator.abs() <= f64::EPSILON { 0.0 } else { (u - knots[i]) / denominator };
771            points[i].scale(alpha).add(points[i - 1].scale(1.0 - alpha))
772        } else {
773            points[i - 1]
774        };
775        out.push(value);
776    }
777    let mut new_knots = Vec::with_capacity(knots.len() + 1);
778    new_knots.extend_from_slice(&knots[..=k]);
779    new_knots.push(u);
780    new_knots.extend_from_slice(&knots[k + 1..]);
781    Ok((out, new_knots))
782}
783
784fn cyclic_distance(a: f64, b: f64, period: f64) -> f64 {
785    let d = (a - b).rem_euclid(period);
786    d.min(period - d)
787}
788