Skip to main content

geop_core_geometry/
meet.rs

1//! The point at given offsets from several surfaces: where a shell moves a
2//! vertex, onto the inner copies of its faces, where a draft moves one onto
3//! its tilted faces, and where a blend's ball touches the faces it rounds.
4//!
5//! One condition per smooth surface ([`Offset`]): the point lies its
6//! `offset` along the surface's unit normal, behind it for a negative one.
7//! A surface may come in pieces — the quarters of a revolved face meeting
8//! at its pole — and is then one condition, not several ([`tangent_groups`]).
9//! Planes through a point add linear conditions. [`meet`] finds the point
10//! nearest its start that meets all of them.
11
12use geop_core_math::{
13    geop_error::{GeopError, GeopResult, WithContext},
14    scalars::Scalar,
15    solvers::{
16        iterate::{Repeat, Step, iterate},
17        linear::shortest_solution,
18    },
19    vector::Vector3,
20    with_context,
21};
22
23use crate::{nurb_surface::NurbSurface3D, search::PROJECT_ITERATIONS, shape::Plane};
24
25/// Newton steps [`meet`] takes at most: between planes it lands in one, and
26/// against curved surfaces it converges quadratically from a start a
27/// thickness or a radius away.
28const MEET_ITERATIONS: usize = 30;
29
30/// A condition on the point [`meet`] finds: that it lie `offset` along the
31/// unit normal of the smooth surface `pieces` are the pieces of, each with
32/// where to start looking for the foot point on it. [`meet`] leaves there
33/// the foot point of its last seed, sharp: a seed for the next search.
34#[derive(Clone, Debug)]
35pub struct Offset<'a> {
36    pub pieces: Vec<(&'a NurbSurface3D, (Scalar, Scalar))>,
37    pub offset: Scalar,
38}
39
40impl<'a> Offset<'a> {
41    /// The condition to lie `offset` from `surface`, its foot point near
42    /// `uv`.
43    pub fn new(surface: &'a NurbSurface3D, uv: (Scalar, Scalar), offset: Scalar) -> Self {
44        Offset {
45            pieces: vec![(surface, uv)],
46            offset,
47        }
48    }
49
50    /// The plane each piece lies in, if it is flat ([`Self::foot`]).
51    fn planes(&self) -> GeopResult<Vec<Option<Plane>>> {
52        self.pieces
53            .iter()
54            .map(|(surface, _)| surface.as_plane())
55            .collect()
56    }
57
58    /// The foot point of `p` on the union of the pieces — the nearest of
59    /// theirs, which piece being a free choice where they tie — and the
60    /// unit normal there. Each piece's `(u, v)` becomes its own foot
61    /// point, sharp, where the next search starts: projection drops the
62    /// surface's second derivatives and converges only linearly on a
63    /// curved one, so it has to start near to end on the foot point.
64    ///
65    /// A piece that is flat ([`Self::planes`]) is its plane: the foot point
66    /// is `p` dropped onto it and the normal the plane's, wherever `p` is.
67    /// Through the patch, the foot point is clamped to it and the normal
68    /// read off `S_u × S_v`, and at a pole neither says what the plane
69    /// does. A disc's pole row is a point only to the width of its control
70    /// points; a hair off it `S_u` is of the order of that hair, and the
71    /// normal there as wide as the width over the hair: at `5e-14` from the
72    /// pole of a cap, `±0.4`. Where a shell moves a vertex onto the axis of
73    /// a revolved cap, every step passes there.
74    ///
75    /// Where the foot point's enclosure reaches an end of the domain, the
76    /// seed is that end. Any point of the enclosure is a valid seed, but at
77    /// a pole the enclosure reaches a hair off it, and a midpoint there
78    /// lies so close to the pole that the surface's derivatives underflow
79    /// and it has no normal; the pole itself has one.
80    fn foot(&mut self, p: &Vector3, planes: &[Option<Plane>]) -> GeopResult<(Vector3, Vector3)> {
81        let mut best: Option<(f64, Vector3, Vector3)> = None;
82        for (k, (surface, uv)) in self.pieces.iter_mut().enumerate() {
83            if let Some(plane) = &planes[k] {
84                let at = plane.project(p);
85                let distance = at.sub(p).norm_sq().upper().to_f64();
86                if best.as_ref().is_none_or(|(d, _, _)| distance < *d) {
87                    best = Some((distance, at, plane.normal));
88                }
89                continue;
90            }
91            let ctx = with_context!("the foot point of {p:?} on piece {k}, from {uv:?}");
92            let (u, v) = surface
93                .project_in_domain(*p, uv.0.sharpen(), uv.1.sharpen(), PROJECT_ITERATIONS)
94                .with_context(ctx)?;
95            let at = surface.evaluate(u, v).with_context(ctx)?;
96            let distance = at.sub(p).norm_sq().upper().to_f64();
97            if best.as_ref().is_none_or(|(d, _, _)| distance < *d) {
98                let normal = surface.normal(u, v).with_context(with_context!(
99                    "the normal at the foot point {:?} of {p:?} on piece {k}",
100                    (u, v)
101                ))?;
102                best = Some((distance, at, normal));
103            }
104            let seed = |t: Scalar, (lo, hi): (Scalar, Scalar)| {
105                if t.could_be_equal(lo) {
106                    lo
107                } else if t.could_be_equal(hi) {
108                    hi
109                } else {
110                    t.sharpen().clamped(lo, hi)
111                }
112            };
113            *uv = (seed(u, surface.domain_u()), seed(v, surface.domain_v()));
114        }
115        let (_, at, normal) = best.ok_or_else(|| GeopError::new("a surface of no pieces"))?;
116        Ok((at, normal))
117    }
118}
119
120/// `pieces`, each a surface and where a point is on it, grouped into the
121/// smooth surfaces they are pieces of there: those whose normals there
122/// could be parallel. The groups as indices into `pieces`, in order.
123///
124/// Decided once, at the point, not at the foot points a search moves
125/// through: each piece clamps those to its own patch, their normals there
126/// differ a little, and nearly equal conditions leave no step to take.
127pub fn tangent_groups(
128    pieces: &[(&NurbSurface3D, (Scalar, Scalar))],
129) -> GeopResult<Vec<Vec<usize>>> {
130    let mut groups: Vec<(Vector3, Vec<usize>)> = Vec::new();
131    for (i, (surface, (u, v))) in pieces.iter().enumerate() {
132        let normal = surface
133            .normal(*u, *v)
134            .with_context(with_context!("the normal of piece {i} at {:?}", (u, v)))?;
135        match groups
136            .iter_mut()
137            .find(|(n, _)| n.prod_cross(&normal).norm_sq().could_be_equal(Scalar::ZERO))
138        {
139            Some((_, members)) => members.push(i),
140            None => groups.push((normal, vec![i])),
141        }
142    }
143    Ok(groups.into_iter().map(|(_, members)| members).collect())
144}
145
146/// The point nearest `start` that lies at every one of `offsets` and in
147/// every one of `planes` (a unit normal and a point each): Gauss-Newton.
148/// Each step measures how far off each condition the iterate is — along
149/// the normal at its foot point on each surface, along each plane's normal
150/// — and takes the shortest step that corrects all of them at once, to
151/// first order. Between planes that is exact in one step; with fewer than
152/// three conditions the point slides along where they meet, by no more
153/// than it has to.
154///
155/// The iterates are seeds for the next step, so they are sharpened — but
156/// they are also the answer: where fewer than three conditions meet, the
157/// point is free to slide, and only the steps taken say how far it has. So
158/// the answer is carried alongside, unsharpened: `start` less every step,
159/// each with the width its own measurement gave it (keep two copies, see
160/// `AGENTS.md`). A caller that only needs a construction aid sharpens it,
161/// where it calls. A fixed point of the sharpened iteration ends it: every
162/// further step would start from the very same seed and only add its width
163/// again — not a tolerance, the seed is bit for bit unchanged.
164///
165/// The answer is checked against every condition at the end: a condition
166/// lost on the way — a foot point clamped to the edge of its patch, a
167/// projection that never moved — is an error here, naming the condition,
168/// not a wrong point found two steps later.
169pub fn meet(
170    start: Vector3,
171    offsets: &mut [Offset],
172    planes: &[(Vector3, Vector3)],
173) -> GeopResult<Vector3> {
174    let mut answer = start;
175    let flat: Vec<Vec<Option<Plane>>> = offsets
176        .iter()
177        .map(Offset::planes)
178        .collect::<GeopResult<_>>()?;
179    iterate(
180        start.sharpen(),
181        start,
182        MEET_ITERATIONS,
183        Repeat::FixedPoint,
184        |next| (next.sharpen(), *next),
185        |seed| -> GeopResult<Step<Vector3>> {
186            let mut rows: Vec<(Vector3, Scalar)> = Vec::new();
187            for (condition, flat) in offsets.iter_mut().zip(&flat) {
188                let (at, normal) = condition.foot(seed, flat)?;
189                rows.push((
190                    normal,
191                    normal.prod_dot(&seed.sub(&at)).sub(condition.offset),
192                ));
193            }
194            for (normal, p) in planes {
195                rows.push((*normal, normal.prod_dot(&seed.sub(p))));
196            }
197            let step = shortest_solution(&rows).with_context(&|e: GeopError| {
198                let feet: Vec<Vec<(Scalar, Scalar)>> = offsets
199                    .iter()
200                    .map(|o| o.pieces.iter().map(|&(_, uv)| uv).collect())
201                    .collect();
202                e.with_context(format!(
203                    "the step of meet from {seed:?}, with the rows {rows:?}, the foot points at {feet:?}"
204                ))
205            })?;
206            answer = answer.sub(&step);
207            Ok(Step::Next(seed.sub(&step)))
208        },
209    )?;
210    let count = offsets.len();
211    for (k, (condition, flat)) in offsets.iter_mut().zip(&flat).enumerate() {
212        let (at, normal) = condition.foot(&answer, flat)?;
213        if !at
214            .add(&normal.prod_scalar(condition.offset))
215            .could_be_equal(&answer)
216        {
217            return Err(GeopError::new(format!(
218                "the point {answer:?}, found from {start:?}, is not {:?} from surface {k} of {} (of {} pieces), whose nearest point is {at:?}, normal {normal:?}",
219                condition.offset,
220                count,
221                condition.pieces.len(),
222            )));
223        }
224    }
225    for (k, (normal, p)) in planes.iter().enumerate() {
226        let off = normal.prod_dot(&answer.sub(p));
227        if !off.could_be_equal(Scalar::ZERO) {
228            return Err(GeopError::new(format!(
229                "the point {answer:?}, found from {start:?}, is {off:?} off plane {k} of {}, through {p:?} with normal {normal:?}",
230                planes.len()
231            )));
232        }
233    }
234    Ok(answer)
235}
236
237#[cfg(test)]
238mod tests {
239    use geop_core_math::{scalars::Scalar, vector::Vector3};
240
241    use super::{Offset, meet};
242    use crate::test_fixtures::sphere_octant;
243
244    fn point(p: [f64; 3]) -> Vector3 {
245        Vector3::from_array(p.map(Scalar::from_f64))
246    }
247
248    /// A point half a unit inside the unit sphere and in the plane
249    /// `x = 0.3`: two conditions, so it slides from where it starts, and
250    /// the answer encloses a point that meets both.
251    #[test]
252    fn slides_onto_an_offset_sphere_and_a_plane() {
253        let sphere = sphere_octant();
254        let half = Scalar::from_f64(0.5);
255        let inward = sphere.normal(half, half).unwrap();
256        // Half a unit towards the center, whichever way the normal faces.
257        let offset = if inward
258            .prod_dot(&point([1., 1., 1.]))
259            .definitely_less(Scalar::ZERO)
260        {
261            half
262        } else {
263            half.neg()
264        };
265        let mut offsets = [Offset::new(&sphere, (half, half), offset)];
266        let plane = (point([1., 0., 0.]), point([0.3, 0., 0.]));
267        let p = meet(point([0.3, 0.3, 0.3]), &mut offsets, &[plane]).unwrap();
268        assert!(p[0].could_be_equal(Scalar::from_f64(0.3)), "{p:?}");
269        assert!(p.norm().could_be_equal(half), "{p:?}");
270        // Fixed point evaluates the rational sphere far less tightly.
271        assert!(p.norm().width().to_f64() < 1e-5, "{p:?}");
272    }
273
274    /// A point over the sphere's pole: its foot point stays there, where
275    /// the parametrization is singular and only the pole itself has a
276    /// normal — and the foot points left behind seed the same answer again.
277    /// (A pole at the start of a domain, where a seed a hair off it would
278    /// underflow, is the shell's test `a_closed_revolved_cylinder`.)
279    #[test]
280    fn stays_over_a_pole() {
281        let sphere = sphere_octant();
282        let half = Scalar::from_f64(0.5);
283        let mut offsets = [Offset::new(&sphere, (half, Scalar::ONE), half)];
284        let p = meet(point([0., 0., 1.2]), &mut offsets, &[]).unwrap();
285        let other = meet(point([0., 0., 1.2]), &mut offsets, &[]).unwrap();
286        assert!(
287            p.could_be_equal(&point([0., 0., 1.5])) || p.could_be_equal(&point([0., 0., 0.5])),
288            "{p:?}"
289        );
290        assert!(other.could_be_equal(&p), "{other:?} {p:?}");
291    }
292}