Skip to main content

brep_kernel/feature_pipeline/pmi/
anchor.rs

1//! Balloon leader anchoring: where a Find-number balloon's arrow head lands.
2//!
3//! The head is DERIVED, never stored, and it lies on a face or an edge of
4//! the target occurrence AS DISPLAYED in the owning view. By default it is
5//! the point of the occurrence's displayed surface nearest the bubble in
6//! screen space ([`nearest_screen_point`]); an optional `anchor` reference pins it to
7//! an edge (the point of that edge nearest the bubble) or to a vertex
8//! (exactly there). Every candidate is exact B-rep geometry: face feet from
9//! the surface projector kept only where they land inside the face's trim,
10//! edge feet on the trimmed span, and the topology vertices — never a
11//! tessellation — so the head lies on the surface to the kernel's spatial
12//! tolerance.
13//!
14//! "As displayed" is what the user sees, and it is decided per solid by the
15//! view ([`HeadScope`]):
16//!
17//! - **Explode poses.** Every enabled explode row naming a solid poses its
18//!   display in turn, `p' = R((p − c) ∘ s) + c + t` per row, so a solid's
19//!   display is `S_n(…S_1(p))`. The bubble is mapped back through the
20//!   solid's own stages ([`pose_unapply`]), projected in the modeling frame,
21//!   and the foot mapped forward ([`pose_apply`]). Two solids of one
22//!   occurrence may carry different stages (an explode of one member), so
23//!   candidates are compared in the DISPLAYED frame, never the modeling one.
24//! - **Section.** A sectioned view clips every display to a half-space
25//!   ([`HalfSpace`], kept where `(p − point)·normal ≥ 0`, the clip's own
26//!   test). A candidate on the removed side is not displayed geometry and
27//!   is never a head; an edge's crossings of the section plane are offered
28//!   instead, so a cut part still takes the arrow on its kept rim. When
29//!   nothing of a solid is kept there is no head on it.
30//! - **Hidden solids** are not in the recipe at all: the resolver lists only
31//!   the occurrence's visible members.
32//!
33//! The recipe ([`BalloonAnchor`]) rides the report's leader geometry so the
34//! viewport can re-derive the head from a dragged bubble on the exact-solid
35//! clone it holds, with the very same functions, and never run the history.
36//!
37//! Legacy 3D projection limits: under a non-uniform explode scale the mapped foot is still on the
38//! displayed surface but is not guaranteed to be the nearest point of it;
39//! under a section the head is the nearest KEPT candidate (a kept foot, a
40//! kept vertex, or an edge's crossing of the plane), not the nearest point of
41//! the cut surface itself, which may lie on a face's section curve. The screen
42//! projector additionally searches planar face/section lines; curved face
43//! section curves are not constructed here.
44
45use serde::{Deserialize, Serialize};
46
47use crate::arrangement::Vec2;
48use crate::classification::{parameter_point_in_face, PolygonClass};
49use crate::feature_pipeline::features::datum::rotate_euler_xyz;
50use crate::projection::{project_point_to_curve, project_point_to_surface};
51use crate::topology::{EdgeRecord, FaceRecord};
52use crate::{BrepSolid, KernelTolerances, NurbsCurve, Vec3};
53
54/// The display pose of an exploded occurrence: `p' = R((p − c) ∘ s) + c + t`
55/// with `R` the intrinsic-XYZ Euler rotation the explode annotation carries.
56#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
57pub struct PmiPose {
58    pub center: [f64; 3],
59    pub translate: [f64; 3],
60    #[serde(rename = "rotateDeg")]
61    pub rotate_deg: [f64; 3],
62    pub scale: [f64; 3],
63}
64
65impl PmiPose {
66    fn radians(&self) -> [f64; 3] {
67        self.rotate_deg.map(f64::to_radians)
68    }
69
70    /// Modeling frame → displayed frame.
71    pub fn apply(&self, point: [f64; 3]) -> [f64; 3] {
72        let c = self.center;
73        let local = Vec3::new(
74            (point[0] - c[0]) * self.scale[0],
75            (point[1] - c[1]) * self.scale[1],
76            (point[2] - c[2]) * self.scale[2],
77        );
78        let rotated = rotate_euler_xyz(local, self.radians());
79        [
80            rotated.x + c[0] + self.translate[0],
81            rotated.y + c[1] + self.translate[1],
82            rotated.z + c[2] + self.translate[2],
83        ]
84    }
85
86    /// Displayed frame → modeling frame (the exact inverse of [`Self::apply`]:
87    /// the rotation is orthonormal, so its inverse is its transpose).
88    pub fn unapply(&self, point: [f64; 3]) -> [f64; 3] {
89        let c = self.center;
90        let moved = Vec3::new(
91            point[0] - c[0] - self.translate[0],
92            point[1] - c[1] - self.translate[1],
93            point[2] - c[2] - self.translate[2],
94        );
95        let e = self.radians();
96        // The rows of R are the images of the unit axes under Rᵀ.
97        let rx = rotate_euler_xyz(Vec3::new(1.0, 0.0, 0.0), e);
98        let ry = rotate_euler_xyz(Vec3::new(0.0, 1.0, 0.0), e);
99        let rz = rotate_euler_xyz(Vec3::new(0.0, 0.0, 1.0), e);
100        let local = Vec3::new(rx.dot(moved), ry.dot(moved), rz.dot(moved));
101        let unscale = |v: f64, s: f64| if s.abs() > 1e-300 { v / s } else { v };
102        [
103            unscale(local.x, self.scale[0]) + c[0],
104            unscale(local.y, self.scale[1]) + c[1],
105            unscale(local.z, self.scale[2]) + c[2],
106        ]
107    }
108
109    /// Whether the pose keeps lengths (unit scale): then the nearest point in
110    /// the modeling frame is the nearest point in the displayed frame.
111    pub fn is_rigid(&self) -> bool {
112        self.scale.iter().all(|s| (s - 1.0).abs() < 1e-12)
113    }
114}
115
116/// Modeling frame → displayed frame through `stages`, in the order the
117/// display applies them.
118pub fn pose_apply(stages: &[PmiPose], point: [f64; 3]) -> [f64; 3] {
119    stages.iter().fold(point, |p, stage| stage.apply(p))
120}
121
122/// Displayed frame → modeling frame: the stages undone last-first.
123pub fn pose_unapply(stages: &[PmiPose], point: [f64; 3]) -> [f64; 3] {
124    stages.iter().rev().fold(point, |p, stage| stage.unapply(p))
125}
126
127/// One solid's display poses, in application order.
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
129pub struct SolidPoses {
130    pub solid: String,
131    pub stages: Vec<PmiPose>,
132}
133
134/// A section half-space in the DISPLAYED frame: geometry is kept where
135/// `(p − point)·normal ≥ 0` (the display clip's own test). A section of
136/// several planes lists the others in `also`: it keeps where ANY plane keeps
137/// (material is removed only where every plane removes it).
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139pub struct HalfSpace {
140    pub point: [f64; 3],
141    pub normal: [f64; 3],
142    /// The section's further planes, `(point, kept-side normal)`.
143    #[serde(default, skip_serializing_if = "Vec::is_empty")]
144    pub also: Vec<([f64; 3], [f64; 3])>,
145}
146
147impl HalfSpace {
148    /// The section of `planes` (`(point, kept-side normal)`, the first being
149    /// the main one); `None` without any.
150    pub fn of(planes: &[([f64; 3], [f64; 3])]) -> Option<Self> {
151        let (&(point, normal), rest) = planes.split_first()?;
152        Some(Self { point, normal, also: rest.to_vec() })
153    }
154
155    /// The signed distance of `p` from the kept region's boundary, positive
156    /// inside: from the plane, or for several planes the largest of their
157    /// distances (≥ 0 exactly where one of them keeps `p`).
158    pub fn signed(&self, p: [f64; 3]) -> f64 {
159        let from = |point: [f64; 3], normal: [f64; 3]| (0..3).map(|i| (p[i] - point[i]) * normal[i]).sum::<f64>();
160        self.also.iter().fold(from(self.point, self.normal), |best, &(point, normal)| best.max(from(point, normal)))
161    }
162
163    /// Whether `p` is displayed (kept), to `tolerance` on the plane itself.
164    pub fn keeps(&self, p: [f64; 3], tolerance: f64) -> bool {
165        self.signed(p) >= -tolerance
166    }
167}
168
169/// How a balloon's head was derived — what the viewport re-derives it with.
170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
171#[serde(tag = "mode", rename_all = "camelCase")]
172pub enum BalloonAnchorMode {
173    /// The nearest point on the occurrence's surface (the default).
174    Surface,
175    /// The nearest point on one edge, named by its owning solid and edge id.
176    Edge {
177        solid: String,
178        #[serde(rename = "edgeId")]
179        edge_id: u64,
180    },
181    /// A picked vertex: the head is fixed there.
182    Point,
183}
184
185/// The head derivation recipe carried on a balloon's leader geometry.
186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
187pub struct BalloonAnchor {
188    /// Camera used to minimize automatic leaders in screen space.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub camera: Option<super::PmiCamera>,
191    /// The occurrence's VISIBLE solid names (scene names) the default head
192    /// projects onto.
193    pub solids: Vec<String>,
194    /// Flattened: `"mode": "surface" | "edge" (+ `solid`, `edgeId`) | "point"`.
195    #[serde(flatten)]
196    pub mode: BalloonAnchorMode,
197    /// The display poses per solid (an un-posed solid is absent).
198    #[serde(default, skip_serializing_if = "Vec::is_empty")]
199    pub poses: Vec<SolidPoses>,
200    /// The view's section half-space, when the view is sectioned.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub section: Option<HalfSpace>,
203}
204
205impl BalloonAnchor {
206    /// `solid`'s display stages (empty when it is not posed).
207    pub fn stages(&self, solid: &str) -> &[PmiPose] {
208        self.poses
209            .iter()
210            .find(|poses| poses.solid == solid)
211            .map_or(&[], |poses| poses.stages.as_slice())
212    }
213
214    /// The scope one solid's head is searched in.
215    pub fn scope(&self, solid: &str) -> HeadScope<'_> {
216        HeadScope {
217            stages: self.stages(solid),
218            section: self.section.as_ref(),
219        }
220    }
221}
222
223/// Where one solid's head may land: its display stages and the view's
224/// section. [`HeadScope::PLAIN`] is the un-posed, unsectioned solid.
225#[derive(Debug, Clone, Copy)]
226pub struct HeadScope<'a> {
227    pub stages: &'a [PmiPose],
228    pub section: Option<&'a HalfSpace>,
229}
230
231impl HeadScope<'_> {
232    pub const PLAIN: HeadScope<'static> = HeadScope {
233        stages: &[],
234        section: None,
235    };
236
237    fn display(&self, modeling: Vec3) -> [f64; 3] {
238        pose_apply(self.stages, [modeling.x, modeling.y, modeling.z])
239    }
240
241    fn modeling(&self, display: [f64; 3]) -> Vec3 {
242        let p = pose_unapply(self.stages, display);
243        Vec3::new(p[0], p[1], p[2])
244    }
245
246    fn keeps(&self, display: [f64; 3], tolerance: f64) -> bool {
247        self.section.is_none_or(|section| section.keeps(display, tolerance))
248    }
249
250    fn is_rigid(&self) -> bool {
251        self.stages.iter().all(PmiPose::is_rigid)
252    }
253}
254
255/// The best candidate so far: its displayed position and squared distance
256/// to the bubble, in the displayed frame.
257struct Best {
258    display: [f64; 3],
259    d2: f64,
260}
261
262fn offer(best: &mut Option<Best>, scope: &HeadScope<'_>, bubble: [f64; 3], tolerance: f64, modeling: Vec3) {
263    let display = scope.display(modeling);
264    if !scope.keeps(display, tolerance) {
265        return;
266    }
267    let d2 = (0..3).map(|i| (display[i] - bubble[i]).powi(2)).sum::<f64>();
268    if best.as_ref().is_none_or(|b| d2 < b.d2) {
269        *best = Some(Best { display, d2 });
270    }
271}
272
273/// The point of `solid`'s DISPLAYED surface nearest `bubble` (both in the
274/// displayed frame): the nearest of its face feet that land inside their
275/// trims, its edge spans and its vertices, as posed by `scope` and kept by
276/// its section; under a section an edge's crossings of the plane are offered
277/// too. `Ok(None)` when no candidate is displayed (the solid is sectioned
278/// away). A face whose projector refuses is skipped — the edges and vertices
279/// are always on the surface — so one bad face never loses the head.
280pub fn nearest_displayed_point(solid: &BrepSolid, scope: HeadScope<'_>, bubble: [f64; 3]) -> Result<Option<[f64; 3]>, String> {
281    let spatial = KernelTolerances::for_solid(solid, 1e-7).spatial();
282    let query = scope.modeling(bubble);
283    let mut best: Option<Best> = None;
284    for vertex in &solid.vertices {
285        offer(&mut best, &scope, bubble, spatial, vertex.point);
286    }
287    for edge in &solid.edges {
288        if edge.degenerate {
289            continue;
290        }
291        offer(&mut best, &scope, bubble, spatial, nearest_point_on_edge(edge, query)?);
292        if let Some(section) = scope.section {
293            for crossing in edge_plane_crossings(edge, &scope, section)? {
294                offer(&mut best, &scope, bubble, spatial, crossing);
295            }
296        }
297    }
298    let rigid = scope.is_rigid();
299    for shell in &solid.shells {
300        for face in &shell.faces {
301            // A face whose control hull cannot come closer than the running
302            // best is skipped without projecting (the hull bounds the
303            // surface; a rigid pose keeps distances, so the modeling-frame
304            // bound applies to the displayed one).
305            if rigid {
306                if let (Some(best), Some(hull_d2)) = (best.as_ref(), hull_distance_squared(face, query)?) {
307                    if hull_d2 > best.d2 {
308                        continue;
309                    }
310                }
311            }
312            let Ok(projection) = project_point_to_surface(&face.surface, query) else {
313                continue;
314            };
315            if rigid && best.as_ref().is_some_and(|b| projection.distance * projection.distance >= b.d2) {
316                continue;
317            }
318            let uv_band = match face.surface.derivatives(projection.u, projection.v, 1) {
319                Ok(derivatives) => crate::tolerance::surface_uv_tolerance(
320                    spatial,
321                    derivatives[1][0].length(),
322                    derivatives[0][1].length(),
323                ),
324                Err(_) => spatial,
325            };
326            let Ok(class) = parameter_point_in_face(
327                face,
328                Vec2 {
329                    x: projection.u,
330                    y: projection.v,
331                },
332                uv_band,
333            ) else {
334                continue;
335            };
336            if matches!(class, PolygonClass::Inside | PolygonClass::Boundary) {
337                offer(&mut best, &scope, bubble, spatial, projection.point);
338            }
339        }
340    }
341    Ok(best.map(|b| b.display))
342}
343
344/// Camera-plane coordinates. Perspective projection is a homogeneous transform,
345/// so projecting NURBS controls preserves the original curve/surface parameters.
346fn screen_control(camera: &super::PmiCamera, scope: HeadScope<'_>, p: crate::Vec4) -> crate::Vec4 {
347    let world = scope.display(Vec3::new(p.x / p.w, p.y / p.w, p.z / p.w));
348    let d = camera.view_direction();
349    let forward = Vec3::new(d[0], d[1], d[2]);
350    let up = Vec3::new(camera.up[0], camera.up[1], camera.up[2]);
351    let right = forward.cross(up);
352    let right = right.scale(1.0 / right.length().max(1e-15));
353    let up = right.cross(forward);
354    let v = Vec3::new(world[0] - camera.eye[0], world[1] - camera.eye[1], world[2] - camera.eye[2]);
355    let weight = match camera.projection {
356        super::PmiProjection::Orthographic { .. } => p.w,
357        super::PmiProjection::Perspective { .. } => p.w * v.dot(forward),
358    };
359    crate::Vec4 { x: v.dot(right) * p.w, y: v.dot(up) * p.w, z: 0.0, w: weight }
360}
361
362pub(super) fn head_distance(camera: Option<&super::PmiCamera>, a: [f64; 3], b: [f64; 3]) -> f64 {
363    let project = |p: [f64; 3]| {
364        if let Some(camera) = camera {
365            let q = screen_control(camera, HeadScope::PLAIN, crate::Vec4::from_point(Vec3::new(p[0], p[1], p[2]), 1.0));
366            [q.x / q.w, q.y / q.w, 0.0]
367        } else { p }
368    };
369    let a = project(a);
370    let b = project(b);
371    (0..3).map(|i| (a[i] - b[i]).powi(2)).sum()
372}
373
374/// A planar face's section line may contain the nearest kept point between
375/// topology edges. Intersect the section with the affine parameter rectangle,
376/// project that segment, and qualify its foot against the original face trim.
377/// Trim-hole endpoints are already covered by the topology edge crossings.
378fn planar_section_feet(face: &FaceRecord, scope: HeadScope<'_>, camera: &super::PmiCamera, query: Vec3, spatial: f64) -> Result<Vec<Vec3>, String> {
379    let Some(section) = scope.section else { return Ok(Vec::new()); };
380    let Some(crate::AnalyticSurface::Plane { .. }) = face.surface.analytic() else { return Ok(Vec::new()); };
381    let [u0, u1] = face.surface.domain_u()?;
382    let [v0, v1] = face.surface.domain_v()?;
383    let corners = [[u0, v0], [u1, v0], [u1, v1], [u0, v1]];
384    let mut feet = Vec::new();
385    for (point, normal) in std::iter::once((section.point, section.normal)).chain(section.also.iter().copied()) {
386        let mut crossings: Vec<[f64; 2]> = Vec::new();
387        for k in 0..4 {
388            let a = corners[k];
389            let b = corners[(k + 1) % 4];
390            let signed = |uv: [f64; 2]| -> Result<f64, String> {
391                let p = scope.display(face.surface.evaluate(uv[0], uv[1])?);
392                Ok((0..3).map(|i| (p[i] - point[i]) * normal[i]).sum())
393            };
394            let da = signed(a)?;
395            let db = signed(b)?;
396            if da.abs() <= spatial { crossings.push(a); }
397            if da * db < 0.0 {
398                let t = da / (da - db);
399                crossings.push([a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])]);
400            }
401        }
402        // Pick the farthest pair (a corner may be encountered twice).
403        let pair = crossings.iter().flat_map(|a| crossings.iter().map(move |b| (*a, *b)))
404            .max_by(|(a, b), (c, d)| {
405                let length = |a: &[f64; 2], b: &[f64; 2]| (a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2);
406                length(a, b).total_cmp(&length(c, d))
407            });
408        let Some((a, b)) = pair else { continue; };
409        if a == b { continue; }
410        let controls = [a, b].into_iter().map(|uv| {
411            face.surface.evaluate(uv[0], uv[1]).map(|p| screen_control(camera, scope, crate::Vec4::from_point(p, 1.0)))
412        }).collect::<Result<Vec<_>, _>>()?;
413        let Ok(line) = NurbsCurve::new(1, vec![0.0, 0.0, 1.0, 1.0], controls) else { continue; };
414        let Ok(foot) = project_point_to_curve(&line, query) else { continue; };
415        let uv = Vec2 { x: a[0] + foot.u * (b[0] - a[0]), y: a[1] + foot.u * (b[1] - a[1]) };
416        let derivatives = face.surface.derivatives(uv.x, uv.y, 1)?;
417        let band = crate::tolerance::surface_uv_tolerance(spatial, derivatives[1][0].length(), derivatives[0][1].length());
418        if matches!(parameter_point_in_face(face, uv, band), Ok(PolygonClass::Inside | PolygonClass::Boundary)) {
419            feet.push(face.surface.evaluate(uv.x, uv.y)?);
420        }
421    }
422    Ok(feet)
423}
424
425/// Minimize the projected distance, then evaluate the original B-rep at the
426/// winning parameters. Depth never biases the screen-space minimization.
427/// Explicit edge/vertex anchors retain their existing semantics.
428pub fn nearest_screen_point(
429    solid: &BrepSolid, scope: HeadScope<'_>, bubble: [f64; 3], camera: Option<&super::PmiCamera>,
430) -> Result<Option<[f64; 3]>, String> {
431    let Some(camera) = camera else { return nearest_displayed_point(solid, scope, bubble); };
432    let q = screen_control(camera, HeadScope::PLAIN, crate::Vec4::from_point(Vec3::new(bubble[0], bubble[1], bubble[2]), 1.0));
433    let query = Vec3::new(q.x / q.w, q.y / q.w, 0.0);
434    let spatial = KernelTolerances::for_solid(solid, 1e-7).spatial();
435    let mut best: Option<Best> = None;
436    let direction = camera.view_direction();
437    let depth = |p: [f64; 3]| (0..3).map(|i| (p[i] - camera.eye[i]) * direction[i]).sum::<f64>();
438    let mut offer = |point: Vec3| {
439        let display = scope.display(point);
440        if !scope.keeps(display, spatial) { return; }
441        if matches!(camera.projection, super::PmiProjection::Perspective { .. }) && depth(display) <= 0.0 { return; }
442        let d2 = head_distance(Some(camera), display, bubble);
443        if d2.is_finite() && best.as_ref().is_none_or(|b| d2 < b.d2 - 1e-12 || ((d2 - b.d2).abs() <= 1e-12 && depth(display) < depth(b.display))) {
444            best = Some(Best { display, d2 });
445        }
446    };
447    for vertex in &solid.vertices { offer(vertex.point); }
448    for edge in &solid.edges {
449        for t in [edge.t0, edge.t1] { offer(edge.curve.evaluate(t)?); }
450        if edge.degenerate { continue; }
451        let span = span_curve(&edge.curve, edge.t0.min(edge.t1), edge.t0.max(edge.t1))?;
452        let projected = NurbsCurve::new(span.degree, span.knots.clone(), span.control_points.iter().map(|p| screen_control(camera, scope, *p)).collect());
453        if let Ok(curve) = projected {
454            if let Ok(foot) = project_point_to_curve(&curve, query) { offer(edge.curve.evaluate(foot.u)?); }
455        }
456        if let Some(section) = scope.section {
457            for p in edge_plane_crossings(edge, &scope, section)? { offer(p); }
458        }
459    }
460    for face in solid.shells.iter().flat_map(|s| &s.faces) {
461        for foot in planar_section_feet(face, scope, camera, query, spatial)? { offer(foot); }
462        let s = &face.surface;
463        // Construct anew: cached analytic carriers and projection grids belong
464        // to the unprojected surface and must not survive this transform.
465        let projected = crate::NurbsSurface::new(s.degree_u, s.degree_v, s.knots_u.clone(), s.knots_v.clone(),
466            s.control_points.iter().map(|row| row.iter().map(|p| screen_control(camera, scope, *p)).collect()).collect());
467        let Ok(surface) = projected else { continue; };
468        let Ok(foot) = project_point_to_surface(&surface, query) else { continue; };
469        let derivatives = s.derivatives(foot.u, foot.v, 1)?;
470        let band = crate::tolerance::surface_uv_tolerance(spatial, derivatives[1][0].length(), derivatives[0][1].length());
471        if matches!(parameter_point_in_face(face, Vec2 { x: foot.u, y: foot.v }, band), Ok(PolygonClass::Inside | PolygonClass::Boundary)) {
472            offer(s.evaluate(foot.u, foot.v)?);
473        }
474    }
475    Ok(best.map(|b| b.display))
476}
477
478/// The point of `solid`'s surface nearest `point`, un-posed and unsectioned
479/// (the modeling-frame form of [`nearest_displayed_point`]).
480pub fn nearest_point_on_solid(solid: &BrepSolid, point: Vec3) -> Result<Vec3, String> {
481    nearest_displayed_point(solid, HeadScope::PLAIN, [point.x, point.y, point.z])?
482        .map(|p| Vec3::new(p[0], p[1], p[2]))
483        .ok_or_else(|| "solid has no geometry".to_string())
484}
485
486/// The squared distance from `point` to the AABB of `face`'s control hull
487/// (`None` for a face without control points).
488fn hull_distance_squared(face: &FaceRecord, point: Vec3) -> Result<Option<f64>, String> {
489    let mut low = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
490    let mut high = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
491    let mut any = false;
492    for row in &face.surface.control_points {
493        for control in row {
494            let p = control.point()?;
495            low = Vec3::new(low.x.min(p.x), low.y.min(p.y), low.z.min(p.z));
496            high = Vec3::new(high.x.max(p.x), high.y.max(p.y), high.z.max(p.z));
497            any = true;
498        }
499    }
500    if !any {
501        return Ok(None);
502    }
503    let axis = |v: f64, lo: f64, hi: f64| if v < lo { lo - v } else if v > hi { v - hi } else { 0.0 };
504    let dx = axis(point.x, low.x, high.x);
505    let dy = axis(point.y, low.y, high.y);
506    let dz = axis(point.z, low.z, high.z);
507    Ok(Some(dx * dx + dy * dy + dz * dz))
508}
509
510/// The point of `edge`'s trimmed span `[t0, t1]` nearest `point`. The carrier
511/// is cut down to the span (a partial arc on a full circle must not answer
512/// with the far side), then projected with the curve projector.
513pub fn nearest_point_on_edge(edge: &EdgeRecord, point: Vec3) -> Result<Vec3, String> {
514    if edge.degenerate {
515        return edge.curve.evaluate(edge.t0);
516    }
517    let span = span_curve(&edge.curve, edge.t0.min(edge.t1), edge.t0.max(edge.t1))?;
518    let projection = project_point_to_curve(&span, point)?;
519    let mut best = (projection.distance, projection.point);
520    // The projector's answer is a local minimum refined from the nearest
521    // sample; the span's ends are exact candidates it may not have seeded.
522    for parameter in [edge.t0, edge.t1] {
523        let end = edge.curve.evaluate(parameter)?;
524        let distance = end.sub(point).length();
525        if distance < best.0 {
526            best = (distance, end);
527        }
528    }
529    Ok(best.1)
530}
531
532/// The point of `edge`'s DISPLAYED span nearest `bubble` within `scope`:
533/// the span's foot when it is kept, else the kept crossing of the section
534/// plane nearest the bubble; `Ok(None)` when the whole span is sectioned
535/// away.
536pub fn nearest_displayed_point_on_edge(edge: &EdgeRecord, scope: HeadScope<'_>, bubble: [f64; 3]) -> Result<Option<[f64; 3]>, String> {
537    let tolerance = 1e-7;
538    let mut best: Option<Best> = None;
539    offer(&mut best, &scope, bubble, tolerance, nearest_point_on_edge(edge, scope.modeling(bubble))?);
540    if let Some(section) = scope.section {
541        if best.is_none() && !edge.degenerate {
542            for crossing in edge_plane_crossings(edge, &scope, section)? {
543                offer(&mut best, &scope, bubble, tolerance, crossing);
544            }
545        }
546    }
547    Ok(best.map(|b| b.display))
548}
549
550/// Where `edge`'s span crosses the section's boundary (modeling points): the
551/// signed distance of the DISPLAYED curve is sampled along the span and each
552/// sign change bisected to the parameter.
553fn edge_plane_crossings(edge: &EdgeRecord, scope: &HeadScope<'_>, section: &HalfSpace) -> Result<Vec<Vec3>, String> {
554    const SAMPLES: usize = 64;
555    let (t0, t1) = (edge.t0.min(edge.t1), edge.t0.max(edge.t1));
556    if !(t1 > t0) {
557        return Ok(Vec::new());
558    }
559    let signed_at = |t: f64| -> Result<f64, String> { Ok(section.signed(scope.display(edge.curve.evaluate(t)?))) };
560    let mut out = Vec::new();
561    let mut previous = (t0, signed_at(t0)?);
562    for k in 1..=SAMPLES {
563        let t = t0 + (t1 - t0) * k as f64 / SAMPLES as f64;
564        let s = signed_at(t)?;
565        if previous.1 == 0.0 {
566            out.push(edge.curve.evaluate(previous.0)?);
567        } else if (previous.1 < 0.0) != (s < 0.0) && s != 0.0 {
568            let (mut a, mut sa, mut b) = (previous.0, previous.1, t);
569            for _ in 0..60 {
570                let m = (a + b) * 0.5;
571                let sm = signed_at(m)?;
572                if (sm < 0.0) == (sa < 0.0) {
573                    a = m;
574                    sa = sm;
575                } else {
576                    b = m;
577                }
578            }
579            out.push(edge.curve.evaluate((a + b) * 0.5)?);
580        }
581        previous = (t, s);
582    }
583    if previous.1 == 0.0 {
584        out.push(edge.curve.evaluate(previous.0)?);
585    }
586    Ok(out)
587}
588
589/// `curve` restricted to `[t0, t1]` (the curve itself when the span is its
590/// whole domain). `NurbsCurve::split` refuses a cut at a domain end, so an end
591/// that coincides with the span is simply not cut.
592fn span_curve(curve: &NurbsCurve, t0: f64, t1: f64) -> Result<NurbsCurve, String> {
593    let [start, end] = curve.domain()?;
594    let eps = (end - start).abs() * 1e-9;
595    let mut span = curve.clone();
596    if t0 > start + eps && t0 < end - eps {
597        span = span.split(t0)?.1;
598    }
599    let [_, span_end] = span.domain()?;
600    if t1 > start + eps && t1 < span_end - eps {
601        span = span.split(t1)?.0;
602    }
603    Ok(span)
604}
605
606/// The head for `bubble` (displayed frame) through `anchor`'s recipe, from
607/// the solids `lookup` serves by scene name. `Ok(None)` for a fixed point
608/// (the head never moves) — and for a solid `lookup` cannot serve, in which
609/// case `missing` names it, so the caller can fetch it and try again. An
610/// error when the recipe has no displayed geometry left for the head (the
611/// anchor edge, or every solid, sectioned away).
612pub fn balloon_head<'a>(
613    anchor: &BalloonAnchor,
614    bubble: [f64; 3],
615    mut lookup: impl FnMut(&str) -> Option<&'a BrepSolid>,
616    missing: &mut Vec<String>,
617) -> Result<Option<[f64; 3]>, String> {
618    match &anchor.mode {
619        BalloonAnchorMode::Point => Ok(None),
620        BalloonAnchorMode::Edge { solid, edge_id } => {
621            let Some(brep) = lookup(solid) else {
622                missing.push(solid.clone());
623                return Ok(None);
624            };
625            let edge = brep
626                .edges
627                .iter()
628                .find(|edge| edge.id == *edge_id)
629                .ok_or_else(|| format!("anchor edge {edge_id} is not on '{solid}'"))?;
630            nearest_displayed_point_on_edge(edge, anchor.scope(solid), bubble)?
631                .map(Some)
632                .ok_or_else(|| format!("anchor edge {edge_id} of '{solid}' is sectioned away in this view"))
633        }
634        BalloonAnchorMode::Surface => {
635            let mut best: Option<Best> = None;
636            for name in &anchor.solids {
637                let Some(brep) = lookup(name) else {
638                    missing.push(name.clone());
639                    continue;
640                };
641                if let Some(display) = nearest_screen_point(brep, anchor.scope(name), bubble, anchor.camera.as_ref())? {
642                    let d2 = head_distance(anchor.camera.as_ref(), display, bubble);
643                    if best.as_ref().is_none_or(|b| d2 < b.d2) {
644                        best = Some(Best { display, d2 });
645                    }
646                }
647            }
648            if !missing.is_empty() {
649                return Ok(None);
650            }
651            best.map(|b| Some(b.display))
652                .ok_or_else(|| "the balloon's occurrence has no displayed geometry in this view".to_string())
653        }
654    }
655}