Skip to main content

brep_kernel/solvers/
assembly_resolve.rs

1//! Assembly selection resolution — a selection reference resolved to an
2//! ANALYTIC frame read from the exact BREP surfaces/curves, never from
3//! tessellation (the retired app's polyline-PCA approximation is the wart
4//! this module kills):
5//!
6//! - planar face → plane (origin + OUTWARD unit normal, plane-z convention)
7//! - cylindrical/conical face → axis (origin + direction), radius when the
8//!   radius is constant (a genuine cylinder)
9//! - spherical face → center + radius
10//! - circular/arc edge → center + axis + radius
11//! - straight edge → the INFINITE carrier line
12//! - vertex → point
13//! - whole component → representative point (aggregate bbox center)
14//!
15//! Anything without an analytic frame is a typed [`ResolveError`] mapping onto
16//! the constraint status vocabulary (`unsupported-selection` /
17//! `invalid-selection`), never a panic.
18//!
19//! # Coordinate space — read this before marshaling mates
20//!
21//! Every resolver reads the geometry EXACTLY as stored on the given
22//! [`BrepSolid`], so the resolved frame is in the SOLID'S OWN space. A
23//! component instance whose resident solids are world-posed (the ACOMP feature
24//! bakes its placement into the geometry) must pass the component's
25//! world→local INVERSE transform to [`SelectionGeometry::transformed`] to
26//! obtain the COMPONENT-LOCAL frame the assembly solver's mate inputs require
27//! ([`MateKind`](crate::MateKind): `*_a`/`*_b` geometry is local to the owning
28//! body, which the solver poses).
29//!
30//! # Lane seam — component lookup
31//!
32//! [`split_component_namespace`] only PARSES the `ACOMP…:` prefix chain off a
33//! namespaced topology name. Mapping that chain to the owning component's
34//! resident solids and its world→local inverse transform is the scene
35//! component registry's job (built in parallel); the resolvers here
36//! deliberately take `(solid, entity)` plus a transform argument instead of a
37//! registry. Vertices carry no kernel names (the renderer's `VertexRef`
38//! convention is owning solid + position), so vertex selections arrive as a
39//! position and snap to the nearest topology vertex.
40
41use crate::topology::{BrepSolid, EdgeRecord, FaceRecord};
42use crate::{AffineTransform, AnalyticSurface, MateAxis, MatePlane, NurbsCurve, Vec3};
43use serde::Serialize;
44
45/// Scale-relative acceptance for the straight/circular edge verification —
46/// the analytic-surface recognition tolerance, for the same reason: exact
47/// kernel-built geometry verifies at machine precision, anything else fails
48/// by orders of magnitude.
49const RESOLVE_TOLERANCE: f64 = 1e-9;
50/// Scale-relative snap distance for vertex-by-position resolution (positions
51/// round-trip through the app as f64 but may be re-serialized).
52const VERTEX_SNAP_TOLERANCE: f64 = 1e-6;
53/// Circle acceptance samples across the edge's trimmed span.
54const CIRCLE_VERIFY_SAMPLES: usize = 16;
55
56// ---------------------------------------------------------------------------
57// Result / error types
58// ---------------------------------------------------------------------------
59
60/// A resolved analytic selection frame, in the coordinate space of the solid
61/// it was resolved from (module docs: pass the component's world→local inverse
62/// to [`Self::transformed`] for the solver's component-local mate inputs).
63///
64/// Cross-lane invariant: [`SelectionGeometry::Axis`]`::radius` is `Some` IFF
65/// the face is a genuine constant-radius cylinder — the gate for the
66/// `tangent_cylinder_plane` mate. Cones, tori, and general revolution faces
67/// resolve with `radius: None`.
68#[derive(Clone, Copy, Debug, Serialize)]
69#[serde(tag = "kind", rename_all = "snake_case")]
70pub enum SelectionGeometry {
71    /// Planar face: origin on the plane (the centre of the face boundary's
72    /// bounding box in the face's own in-plane basis — a fixed point of the
73    /// body, see `planar_face`) + OUTWARD unit normal (face sense respected,
74    /// per the one plane-z direction convention).
75    Plane { origin: Vec3, normal: Vec3 },
76    /// Axis-bearing face (cylinder / cone / torus / general revolution):
77    /// a point on the axis + unit direction.
78    Axis {
79        origin: Vec3,
80        direction: Vec3,
81        radius: Option<f64>,
82    },
83    /// Spherical face.
84    Sphere { center: Vec3, radius: f64 },
85    /// Circular or arc edge; `axis` follows the right-hand rule along the
86    /// edge's increasing-parameter direction.
87    Circle { center: Vec3, axis: Vec3, radius: f64 },
88    /// Straight edge: the INFINITE carrier line (origin = chord midpoint,
89    /// unit direction start→end). Distance mates measure against the carrier,
90    /// never the clamped finite segment.
91    Line { origin: Vec3, direction: Vec3 },
92    /// Vertex (or whole-component representative point).
93    Point { position: Vec3 },
94}
95
96/// Typed resolution failure. [`Self::status`] maps each variant onto the
97/// constraint status vocabulary so the constraint lifecycle reports it
98/// without string-matching messages.
99#[derive(Clone, Debug, PartialEq, Serialize)]
100pub enum ResolveError {
101    /// No such entity on the solid (or no vertex within snap distance).
102    NotFound { name: String },
103    /// The entity exists but carries no analytic frame (freeform surface,
104    /// spline edge, degenerate edge).
105    Unsupported { name: String, detail: String },
106    /// The entity's geometry failed to evaluate.
107    Geometry { name: String, detail: String },
108}
109
110impl ResolveError {
111    /// The constraint-status word this failure maps to.
112    pub fn status(&self) -> &'static str {
113        match self {
114            ResolveError::NotFound { .. } | ResolveError::Geometry { .. } => "invalid-selection",
115            ResolveError::Unsupported { .. } => "unsupported-selection",
116        }
117    }
118}
119
120impl std::fmt::Display for ResolveError {
121    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        match self {
123            ResolveError::NotFound { name } => write!(formatter, "selection '{name}' not found"),
124            ResolveError::Unsupported { name, detail } => {
125                write!(formatter, "selection '{name}' has no analytic frame: {detail}")
126            }
127            ResolveError::Geometry { name, detail } => {
128                write!(formatter, "selection '{name}' failed to resolve: {detail}")
129            }
130        }
131    }
132}
133
134impl SelectionGeometry {
135    /// The solver's plane input, for planar-face selections.
136    pub fn mate_plane(&self) -> Option<MatePlane> {
137        match *self {
138            SelectionGeometry::Plane { origin, normal } => Some(MatePlane {
139                origin: triple(origin),
140                normal: triple(normal),
141            }),
142            _ => None,
143        }
144    }
145
146    /// The solver's axis input, for every axis-bearing selection: an axis
147    /// face, a circular edge (center + circle axis), or a straight edge's
148    /// carrier line.
149    pub fn mate_axis(&self) -> Option<MateAxis> {
150        let (origin, direction) = match *self {
151            SelectionGeometry::Axis {
152                origin, direction, ..
153            }
154            | SelectionGeometry::Line { origin, direction } => (origin, direction),
155            SelectionGeometry::Circle { center, axis, .. } => (center, axis),
156            _ => return None,
157        };
158        Some(MateAxis {
159            origin: triple(origin),
160            direction: triple(direction),
161        })
162    }
163
164    /// The per-kind anchor point: plane / axis / line origin, sphere / circle
165    /// center, the point itself.
166    pub fn representative_point(&self) -> Vec3 {
167        match *self {
168            SelectionGeometry::Plane { origin, .. }
169            | SelectionGeometry::Axis { origin, .. }
170            | SelectionGeometry::Line { origin, .. } => origin,
171            SelectionGeometry::Sphere { center, .. }
172            | SelectionGeometry::Circle { center, .. } => center,
173            SelectionGeometry::Point { position } => position,
174        }
175    }
176
177    /// Map the frame through `transform` — pass the owning component's
178    /// world→local inverse to express a world-resolved frame in the
179    /// component-local coordinates the solver's mate inputs require. Rigid /
180    /// uniform-similarity matrices only (component poses are rigid by spec):
181    /// radii scale by the uniform factor; a shearing or non-uniformly scaling
182    /// matrix is refused (it has no analytic image for circles/cylinders).
183    pub fn transformed(&self, transform: &AffineTransform) -> Result<Self, ResolveError> {
184        let scale = uniform_scale(transform).ok_or_else(|| ResolveError::Unsupported {
185            name: "transform".into(),
186            detail: "selection frames transform only by rigid/uniform-scale matrices".into(),
187        })?;
188        let direction = |vector: Vec3| {
189            linear(transform, vector)
190                .normalized()
191                .map_err(|error| ResolveError::Geometry {
192                    name: "transform".into(),
193                    detail: error,
194                })
195        };
196        Ok(match *self {
197            SelectionGeometry::Plane { origin, normal } => SelectionGeometry::Plane {
198                origin: transform.point(origin),
199                normal: direction(normal)?,
200            },
201            SelectionGeometry::Axis {
202                origin,
203                direction: axis,
204                radius,
205            } => SelectionGeometry::Axis {
206                origin: transform.point(origin),
207                direction: direction(axis)?,
208                radius: radius.map(|radius| radius * scale),
209            },
210            SelectionGeometry::Sphere { center, radius } => SelectionGeometry::Sphere {
211                center: transform.point(center),
212                radius: radius * scale,
213            },
214            SelectionGeometry::Circle {
215                center,
216                axis,
217                radius,
218            } => SelectionGeometry::Circle {
219                center: transform.point(center),
220                axis: direction(axis)?,
221                radius: radius * scale,
222            },
223            SelectionGeometry::Line {
224                origin,
225                direction: axis,
226            } => SelectionGeometry::Line {
227                origin: transform.point(origin),
228                direction: direction(axis)?,
229            },
230            SelectionGeometry::Point { position } => SelectionGeometry::Point {
231                position: transform.point(position),
232            },
233        })
234    }
235}
236
237fn triple(vector: Vec3) -> [f64; 3] {
238    [vector.x, vector.y, vector.z]
239}
240
241/// The linear (rotation/scale) part of the transform applied to a vector.
242pub(crate) fn linear(transform: &AffineTransform, vector: Vec3) -> Vec3 {
243    let m = transform.elements;
244    Vec3::new(
245        m[0] * vector.x + m[1] * vector.y + m[2] * vector.z,
246        m[4] * vector.x + m[5] * vector.y + m[6] * vector.z,
247        m[8] * vector.x + m[9] * vector.y + m[10] * vector.z,
248    )
249}
250
251/// The uniform scale factor of a rigid/similarity matrix: columns of equal
252/// length AND pairwise orthogonal (equal lengths alone admit shear).
253fn uniform_scale(transform: &AffineTransform) -> Option<f64> {
254    let m = transform.elements;
255    let columns = [
256        Vec3::new(m[0], m[4], m[8]),
257        Vec3::new(m[1], m[5], m[9]),
258        Vec3::new(m[2], m[6], m[10]),
259    ];
260    let lengths = [
261        columns[0].length(),
262        columns[1].length(),
263        columns[2].length(),
264    ];
265    let scale = (lengths[0] + lengths[1] + lengths[2]) / 3.0;
266    if !(scale > 0.0) {
267        return None;
268    }
269    if lengths
270        .iter()
271        .any(|length| (length - scale).abs() > 1e-9 * scale)
272    {
273        return None;
274    }
275    let orthogonality = 1e-9 * scale * scale;
276    if columns[0].dot(columns[1]).abs() > orthogonality
277        || columns[1].dot(columns[2]).abs() > orthogonality
278        || columns[0].dot(columns[2]).abs() > orthogonality
279    {
280        return None;
281    }
282    Some(scale)
283}
284
285// ---------------------------------------------------------------------------
286// Namespace parsing
287// ---------------------------------------------------------------------------
288
289/// Split a possibly-namespaced topology name into its `ACOMP…:` component-id
290/// chain (outermost first) and the remaining component-LOCAL name. A segment
291/// is peeled only when it matches `ACOMP<digits>` AND is followed by `:`, so a
292/// bare whole-component reference stays in the LOCAL position — callers detect
293/// it with [`is_component_reference`]:
294///
295/// - `"ACOMP2:Extrude1|Extrude1_top[0]"` → `(["ACOMP2"], "Extrude1|…")`
296/// - `"ACOMP3:ACOMP1:S1:G20"` → `(["ACOMP3", "ACOMP1"], "S1:G20")`
297/// - `"ACOMP2"` → `([], "ACOMP2")` (whole-component selection)
298/// - `"ACOMP3:ACOMP1"` → `(["ACOMP3"], "ACOMP1")` (nested whole-component)
299/// - `"S1:G20"` → `([], "S1:G20")` (sketch-child names never namespace)
300pub fn split_component_namespace(name: &str) -> (Vec<&str>, &str) {
301    let mut chain = Vec::new();
302    let mut local = name;
303    while let Some((head, tail)) = local.split_once(':') {
304        if !is_component_reference(head) {
305            break;
306        }
307        chain.push(head);
308        local = tail;
309    }
310    (chain, local)
311}
312
313/// `ACOMP<digits>` — an ACOMP feature id, i.e. a whole-component selection or
314/// one namespace segment of a nested chain.
315pub fn is_component_reference(name: &str) -> bool {
316    name.strip_prefix("ACOMP")
317        .map(|digits| !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()))
318        .unwrap_or(false)
319}
320
321// ---------------------------------------------------------------------------
322// Entity resolution
323// ---------------------------------------------------------------------------
324
325/// Resolve a component-LOCAL topology name (already namespace-stripped)
326/// against one solid: faces first, then edges. Names never collide across the
327/// two kinds under the deterministic-naming scheme; the order only settles
328/// pathological ties.
329pub fn resolve_named_selection(
330    solid: &BrepSolid,
331    name: &str,
332) -> Result<SelectionGeometry, ResolveError> {
333    if let Some(record) = solid
334        .shells
335        .iter()
336        .flat_map(|shell| &shell.faces)
337        .find(|face| face.name.as_deref() == Some(name))
338    {
339        return resolve_face_record(solid, record);
340    }
341    if let Some(record) = solid
342        .edges
343        .iter()
344        .find(|edge| edge.name.as_deref() == Some(name))
345    {
346        return resolve_edge_record(record);
347    }
348    Err(ResolveError::NotFound { name: name.into() })
349}
350
351/// Resolve a face by topology id (the scene-map `FaceRef` lane: the caller
352/// already resolved the selection name to `(handle, face_id)`).
353pub fn resolve_face_selection(
354    solid: &BrepSolid,
355    face_id: u64,
356) -> Result<SelectionGeometry, ResolveError> {
357    let record = solid
358        .shells
359        .iter()
360        .flat_map(|shell| &shell.faces)
361        .find(|face| face.id == face_id)
362        .ok_or_else(|| ResolveError::NotFound {
363            name: format!("face {face_id}"),
364        })?;
365    resolve_face_record(solid, record)
366}
367
368/// Resolve an edge by topology id (the `EdgeRef` lane).
369pub fn resolve_edge_selection(
370    solid: &BrepSolid,
371    edge_id: u64,
372) -> Result<SelectionGeometry, ResolveError> {
373    let record = solid
374        .edges
375        .iter()
376        .find(|edge| edge.id == edge_id)
377        .ok_or_else(|| ResolveError::NotFound {
378            name: format!("edge {edge_id}"),
379        })?;
380    resolve_edge_record(record)
381}
382
383/// Resolve a vertex selection by position (vertices have no kernel names; the
384/// renderer references them by owning solid + position). Nearest topology
385/// vertex wins — deterministic when two vertices are close — then the
386/// scale-relative snap tolerance gates acceptance; the returned point is the
387/// EXACT vertex position, not the query.
388pub fn resolve_vertex_selection(
389    solid: &BrepSolid,
390    position: Vec3,
391) -> Result<SelectionGeometry, ResolveError> {
392    let mut best: Option<(f64, Vec3)> = None;
393    for vertex in &solid.vertices {
394        let distance = vertex.point.sub(position).length();
395        if best
396            .map(|(best_distance, _)| distance < best_distance)
397            .unwrap_or(true)
398        {
399            best = Some((distance, vertex.point));
400        }
401    }
402    match best {
403        Some((distance, point))
404            if distance <= VERTEX_SNAP_TOLERANCE * crate::solid_scale(solid) =>
405        {
406            Ok(SelectionGeometry::Point { position: point })
407        }
408        _ => Err(ResolveError::NotFound {
409            name: format!(
410                "vertex near ({}, {}, {})",
411                position.x, position.y, position.z
412            ),
413        }),
414    }
415}
416
417/// Whole-component representative point: the center of the aggregate AABB of
418/// every face surface's control-point hull (plus topology vertices) across the
419/// component's solids. The hull BOUNDS the exact surfaces (convex-hull
420/// property) without touching tessellation; it overshoots curved faces, so
421/// this is a representative anchor for coincident/parallel-with-component
422/// semantics, not an exact bounding box.
423pub fn resolve_component_point(solids: &[&BrepSolid]) -> Result<SelectionGeometry, ResolveError> {
424    let mut low = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
425    let mut high = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
426    let mut any = false;
427    let mut include = |point: Vec3| {
428        low = Vec3::new(low.x.min(point.x), low.y.min(point.y), low.z.min(point.z));
429        high = Vec3::new(high.x.max(point.x), high.y.max(point.y), high.z.max(point.z));
430        any = true;
431    };
432    for solid in solids {
433        for vertex in &solid.vertices {
434            include(vertex.point);
435        }
436        for shell in &solid.shells {
437            for face in &shell.faces {
438                for row in &face.surface.control_points {
439                    for control in row {
440                        let point = control.point().map_err(|error| ResolveError::Geometry {
441                            name: "component".into(),
442                            detail: error,
443                        })?;
444                        include(point);
445                    }
446                }
447            }
448        }
449    }
450    if !any {
451        return Err(ResolveError::Geometry {
452            name: "component".into(),
453            detail: "component has no geometry to anchor".into(),
454        });
455    }
456    Ok(SelectionGeometry::Point {
457        position: low.add(high).scale(0.5),
458    })
459}
460
461// ---------------------------------------------------------------------------
462// Face resolution
463// ---------------------------------------------------------------------------
464
465fn face_label(record: &FaceRecord) -> String {
466    record
467        .name
468        .clone()
469        .unwrap_or_else(|| format!("face {}", record.id))
470}
471
472fn resolve_face_record(
473    solid: &BrepSolid,
474    record: &FaceRecord,
475) -> Result<SelectionGeometry, ResolveError> {
476    match record.surface.analytic() {
477        Some(AnalyticSurface::RuledRevolution {
478            frame, rho0, rho1, ..
479        }) => {
480            // Meridional generatrix (reconstruction-verified), so equal end
481            // radii already mean a constant radius: a genuine cylinder.
482            let span = rho0.abs().max(rho1.abs());
483            let radius = ((rho0 - rho1).abs() <= RESOLVE_TOLERANCE * (1.0 + span))
484                .then_some(0.5 * (rho0 + rho1));
485            Ok(SelectionGeometry::Axis {
486                origin: frame.origin,
487                direction: frame.axis,
488                radius,
489            })
490        }
491        Some(AnalyticSurface::Sphere { frame, radius }) => Ok(SelectionGeometry::Sphere {
492            center: frame.origin,
493            radius: *radius,
494        }),
495        Some(AnalyticSurface::Torus { frame, .. }) => Ok(SelectionGeometry::Axis {
496            origin: frame.origin,
497            direction: frame.axis,
498            radius: None,
499        }),
500        Some(AnalyticSurface::Revolution {
501            frame, generatrix, ..
502        }) => Ok(SelectionGeometry::Axis {
503            origin: frame.origin,
504            direction: frame.axis,
505            radius: revolution_cylinder_radius(frame, generatrix),
506        }),
507        // Exact plane, or unrecognized (imported) geometry that may still be
508        // planar — one shared sampled-planarity lane, `face_frame` parity.
509        Some(AnalyticSurface::Plane { .. }) | None => planar_face(solid, record),
510    }
511}
512
513/// `Some(radius)` when a general-revolution generatrix is a straight line at
514/// CONSTANT radial distance from the axis — a partial cylinder (e.g. a 180°
515/// revolve wall). Equal END radii alone are not enough: a line skew to the
516/// axis revolves into a hyperboloid whose radius dips between equal ends. But
517/// squared radial distance along a straight line is a QUADRATIC function of
518/// arc length, so three equal samples (ends + midpoint) force it constant.
519fn revolution_cylinder_radius(
520    frame: &crate::RevolutionFrame,
521    generatrix: &NurbsCurve,
522) -> Option<f64> {
523    if !control_net_is_colinear(generatrix) {
524        return None;
525    }
526    let [t0, t1] = generatrix.domain().ok()?;
527    let radial = |t: f64| -> Option<f64> {
528        let point = generatrix.evaluate(t).ok()?;
529        let delta = point.sub(frame.origin);
530        Some(delta.sub(frame.axis.scale(delta.dot(frame.axis))).length())
531    };
532    let rho0 = radial(t0)?;
533    let rho_mid = radial(0.5 * (t0 + t1))?;
534    let rho1 = radial(t1)?;
535    let span = rho0.abs().max(rho1.abs());
536    let tolerance = RESOLVE_TOLERANCE * (1.0 + span);
537    ((rho0 - rho1).abs() <= tolerance && (rho_mid - rho0).abs() <= tolerance)
538        .then_some((rho0 + rho_mid + rho1) / 3.0)
539}
540
541/// Planar-face resolution: sampled-constant-normal acceptance (the established
542/// `face_frame` planarity test, densified to a 3×3 grid), OUTWARD normal via
543/// the face sense, origin = the centre of the face boundary's bounding box
544/// taken in the face's OWN in-plane basis, projected onto the plane.
545///
546/// The basis is the surface's u-tangent at the patch midpoint (its normal
547/// component removed) and the normal's cross with it. A component pose moves
548/// the control net rigidly and that basis with it, so the box of the same
549/// boundary samples is the same box and the anchor is a FIXED point of the
550/// body. A world-axis box is not: on a face with no centre of symmetry (a
551/// triangle, an L) its centre slides over the face as the body turns — and the
552/// assembly angle gizmo, hinged on the two face anchors, slid with it on
553/// every edit (`planar_face_anchor_is_a_fixed_body_point_on_an_asymmetric_face`).
554/// For a centrally symmetric face (rectangle, disc) both rules name the same
555/// point. The tangent test is scale-free (a stretched knot domain shrinks the
556/// partials without moving the plane); the v-partial is the body-fixed
557/// fallback, and a face whose partials both vanish at the midpoint cannot
558/// reach this code — its normal would have failed above.
559fn planar_face(solid: &BrepSolid, record: &FaceRecord) -> Result<SelectionGeometry, ResolveError> {
560    let geometry = (|| -> Result<Option<SelectionGeometry>, String> {
561        let [u0, u1] = record.surface.domain_u()?;
562        let [v0, v1] = record.surface.domain_v()?;
563        let (um, vm) = ((u0 + u1) * 0.5, (v0 + v1) * 0.5);
564        let center_normal = record.surface.normal(um, vm)?;
565        for u in [u0, um, u1] {
566            for v in [v0, vm, v1] {
567                if record.surface.normal(u, v)?.dot(center_normal) < 1.0 - 1e-6 {
568                    return Ok(None);
569                }
570            }
571        }
572        let normal = if record.same_sense {
573            center_normal
574        } else {
575            center_normal.scale(-1.0)
576        }
577        .normalized()?;
578
579        // The face's own in-plane basis (body-fixed, see the doc above). The
580        // test is SCALE-FREE — the in-plane part must carry the derivative's
581        // own magnitude, whatever that magnitude is — because a knot domain
582        // can be stretched arbitrarily without moving a point of the plane,
583        // and an absolute floor would send such a face to a pose-dependent
584        // rule (`planar_anchor_is_body_fixed_whatever_the_parameter_scale`).
585        // Both partials are tried; both are body-fixed. Neither can fail for
586        // a face that got here: `normal(um, vm)` above succeeded, so
587        // |∂u × ∂v| > 1e-12 at the midpoint — both partials are non-zero —
588        // and the accepted constant normal is their cross, so each lies in
589        // the plane. The error is the unreachable branch, not a world axis.
590        let plane_point = record.surface.evaluate(um, vm)?;
591        let partials = record.surface.derivatives(um, vm, 1)?;
592        let in_plane = |partial: Vec3| -> Option<Vec3> {
593            let tangent = partial.sub(normal.scale(partial.dot(normal)));
594            let length = tangent.length();
595            (length.is_finite() && length > 1e-6 * partial.length() && length > 0.0)
596                .then(|| tangent.scale(1.0 / length))
597        };
598        let e1 = in_plane(partials[1][0])
599            .or_else(|| in_plane(partials[0][1]))
600            .ok_or_else(|| {
601                "planar face: both surface partials degenerate at the patch midpoint".to_string()
602            })?;
603        let e2 = normal.cross(e1).normalized()?;
604        // Boundary bounding box in that basis, from the face loops'
605        // non-degenerate edges; the patch midpoint for loop-less faces.
606        let mut low = [f64::INFINITY; 2];
607        let mut high = [f64::NEG_INFINITY; 2];
608        let mut any = false;
609        for loop_record in &record.loops {
610            for coedge in &loop_record.coedges {
611                let Some(edge) = solid.edges.iter().find(|edge| edge.id == coedge.edge_id) else {
612                    continue;
613                };
614                if edge.degenerate {
615                    continue;
616                }
617                for step in 0..=4 {
618                    let t = edge.t0 + (edge.t1 - edge.t0) * (step as f64 / 4.0);
619                    let offset = edge.curve.evaluate(t)?.sub(plane_point);
620                    let (a, b) = (offset.dot(e1), offset.dot(e2));
621                    low = [low[0].min(a), low[1].min(b)];
622                    high = [high[0].max(a), high[1].max(b)];
623                    any = true;
624                }
625            }
626        }
627        let center = if any {
628            plane_point
629                .add(e1.scale((low[0] + high[0]) * 0.5))
630                .add(e2.scale((low[1] + high[1]) * 0.5))
631        } else {
632            plane_point
633        };
634        // Project onto the plane so the origin lies exactly on it (the basis
635        // is in-plane; this clears rounding).
636        let signed = center.sub(plane_point).dot(normal);
637        let origin = center.sub(normal.scale(signed));
638        Ok(Some(SelectionGeometry::Plane { origin, normal }))
639    })();
640    match geometry {
641        Ok(Some(frame)) => Ok(frame),
642        Ok(None) => Err(ResolveError::Unsupported {
643            name: face_label(record),
644            detail: "face carries no analytic frame (freeform surface)".into(),
645        }),
646        Err(error) => Err(ResolveError::Geometry {
647            name: face_label(record),
648            detail: error,
649        }),
650    }
651}
652
653// ---------------------------------------------------------------------------
654// Edge resolution
655// ---------------------------------------------------------------------------
656
657fn edge_label(record: &EdgeRecord) -> String {
658    record
659        .name
660        .clone()
661        .unwrap_or_else(|| format!("edge {}", record.id))
662}
663
664fn resolve_edge_record(record: &EdgeRecord) -> Result<SelectionGeometry, ResolveError> {
665    if record.degenerate {
666        return Err(ResolveError::Unsupported {
667            name: edge_label(record),
668            detail: "degenerate edge".into(),
669        });
670    }
671    match edge_geometry(record) {
672        Ok(Some(frame)) => Ok(frame),
673        Ok(None) => Err(ResolveError::Unsupported {
674            name: edge_label(record),
675            detail: "edge is neither straight nor circular".into(),
676        }),
677        Err(error) => Err(ResolveError::Geometry {
678            name: edge_label(record),
679            detail: error,
680        }),
681    }
682}
683
684fn edge_geometry(record: &EdgeRecord) -> Result<Option<SelectionGeometry>, String> {
685    if let Some(line) = straight_edge_line(record)? {
686        return Ok(Some(line));
687    }
688    circular_edge_circle(record)
689}
690
691/// The whole curve's extent, for scale-relative acceptance.
692fn control_net_extent(curve: &NurbsCurve) -> f64 {
693    let mut extent = 0.0_f64;
694    for control in &curve.control_points {
695        if let Ok(point) = control.point() {
696            extent = extent
697                .max(point.x.abs())
698                .max(point.y.abs())
699                .max(point.z.abs());
700        }
701    }
702    extent
703}
704
705/// A colinear control net is EXACT proof of a straight curve (convex-hull
706/// property), and every straight curve this kernel builds — `make_line`
707/// products through any split/degree change — keeps its net colinear.
708fn control_net_is_colinear(curve: &NurbsCurve) -> bool {
709    let points: Vec<Vec3> = curve
710        .control_points
711        .iter()
712        .filter_map(|control| control.point().ok())
713        .collect();
714    let Some((&first, rest)) = points.split_first() else {
715        return false;
716    };
717    let tolerance = RESOLVE_TOLERANCE * (1.0 + control_net_extent(curve));
718    let Some(direction) = rest
719        .iter()
720        .map(|point| point.sub(first))
721        .max_by(|a, b| a.length().total_cmp(&b.length()))
722        .and_then(|chord| chord.normalized().ok())
723    else {
724        return false;
725    };
726    points.iter().all(|point| {
727        let offset = point.sub(first);
728        offset.sub(direction.scale(offset.dot(direction))).length() <= tolerance
729    })
730}
731
732/// Straight edge → its infinite carrier line, oriented start→end over the
733/// trimmed span, origin at the chord midpoint.
734fn straight_edge_line(record: &EdgeRecord) -> Result<Option<SelectionGeometry>, String> {
735    if !control_net_is_colinear(&record.curve) {
736        return Ok(None);
737    }
738    let start = record.curve.evaluate(record.t0)?;
739    let end = record.curve.evaluate(record.t1)?;
740    let chord = end.sub(start);
741    if chord.length() <= RESOLVE_TOLERANCE * (1.0 + control_net_extent(&record.curve)) {
742        // Zero-length trim of a straight curve — nothing to orient by.
743        return Ok(None);
744    }
745    Ok(Some(SelectionGeometry::Line {
746        origin: start.add(end).scale(0.5),
747        direction: chord.normalized()?,
748    }))
749}
750
751/// Circular/arc edge → center + axis + radius: a candidate circle from three
752/// interior samples (distinct even on a closed full circle), then VERIFIED at
753/// [`CIRCLE_VERIFY_SAMPLES`] points — equidistant from the center and coplanar
754/// — so ellipses, splines, and helices fail cleanly while exact kernel arcs
755/// pass at machine precision.
756fn circular_edge_circle(record: &EdgeRecord) -> Result<Option<SelectionGeometry>, String> {
757    let span = record.t1 - record.t0;
758    if !(span > 0.0) {
759        return Ok(None);
760    }
761    let at = |fraction: f64| record.curve.evaluate(record.t0 + span * fraction);
762    let (a, b, c) = (at(1.0 / 6.0)?, at(0.5)?, at(5.0 / 6.0)?);
763    let Some(center) = crate::analytic_surface::circumcenter(a, b, c) else {
764        return Ok(None);
765    };
766    // Right-hand rule along the increasing-parameter direction.
767    let Ok(axis) = b.sub(a).cross(c.sub(b)).normalized() else {
768        return Ok(None);
769    };
770    let radius = a.sub(center).length();
771    let tolerance = RESOLVE_TOLERANCE * (1.0 + control_net_extent(&record.curve));
772    for step in 0..=CIRCLE_VERIFY_SAMPLES {
773        let point = at(step as f64 / CIRCLE_VERIFY_SAMPLES as f64)?;
774        let delta = point.sub(center);
775        if (delta.length() - radius).abs() > tolerance || delta.dot(axis).abs() > tolerance {
776            return Ok(None);
777        }
778    }
779    Ok(Some(SelectionGeometry::Circle {
780        center,
781        axis,
782        radius,
783    }))
784}
785
786// ---------------------------------------------------------------------------
787// Tests
788// ---------------------------------------------------------------------------
789