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}