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 — or a Pattern Component instance
315/// id (`APAT1.ACOMP3.i4`), which namespaces its geometry the same way.
316pub fn is_component_reference(name: &str) -> bool {
317    name.strip_prefix("ACOMP")
318        .map(|digits| !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()))
319        .unwrap_or(false)
320        || crate::feature_pipeline::features::assembly_pattern::parse_instance_id(name).is_some()
321}
322
323// ---------------------------------------------------------------------------
324// Entity resolution
325// ---------------------------------------------------------------------------
326
327/// Resolve a component-LOCAL topology name (already namespace-stripped)
328/// against one solid: faces first, then edges. Names never collide across the
329/// two kinds under the deterministic-naming scheme; the order only settles
330/// pathological ties.
331pub fn resolve_named_selection(
332    solid: &BrepSolid,
333    name: &str,
334) -> Result<SelectionGeometry, ResolveError> {
335    if let Some(record) = solid
336        .shells
337        .iter()
338        .flat_map(|shell| &shell.faces)
339        .find(|face| face.name.as_deref() == Some(name))
340    {
341        return resolve_face_record(solid, record);
342    }
343    if let Some(record) = solid
344        .edges
345        .iter()
346        .find(|edge| edge.name.as_deref() == Some(name))
347    {
348        return resolve_edge_record(record);
349    }
350    Err(ResolveError::NotFound { name: name.into() })
351}
352
353/// Resolve a face by topology id (the scene-map `FaceRef` lane: the caller
354/// already resolved the selection name to `(handle, face_id)`).
355pub fn resolve_face_selection(
356    solid: &BrepSolid,
357    face_id: u64,
358) -> Result<SelectionGeometry, ResolveError> {
359    let record = solid
360        .shells
361        .iter()
362        .flat_map(|shell| &shell.faces)
363        .find(|face| face.id == face_id)
364        .ok_or_else(|| ResolveError::NotFound {
365            name: format!("face {face_id}"),
366        })?;
367    resolve_face_record(solid, record)
368}
369
370/// Resolve an edge by topology id (the `EdgeRef` lane).
371pub fn resolve_edge_selection(
372    solid: &BrepSolid,
373    edge_id: u64,
374) -> Result<SelectionGeometry, ResolveError> {
375    let record = solid
376        .edges
377        .iter()
378        .find(|edge| edge.id == edge_id)
379        .ok_or_else(|| ResolveError::NotFound {
380            name: format!("edge {edge_id}"),
381        })?;
382    resolve_edge_record(record)
383}
384
385/// Resolve a vertex selection by position (vertices have no kernel names; the
386/// renderer references them by owning solid + position). Nearest topology
387/// vertex wins — deterministic when two vertices are close — then the
388/// scale-relative snap tolerance gates acceptance; the returned point is the
389/// EXACT vertex position, not the query.
390pub fn resolve_vertex_selection(
391    solid: &BrepSolid,
392    position: Vec3,
393) -> Result<SelectionGeometry, ResolveError> {
394    let mut best: Option<(f64, Vec3)> = None;
395    for vertex in &solid.vertices {
396        let distance = vertex.point.sub(position).length();
397        if best
398            .map(|(best_distance, _)| distance < best_distance)
399            .unwrap_or(true)
400        {
401            best = Some((distance, vertex.point));
402        }
403    }
404    match best {
405        Some((distance, point))
406            if distance <= VERTEX_SNAP_TOLERANCE * crate::solid_scale(solid) =>
407        {
408            Ok(SelectionGeometry::Point { position: point })
409        }
410        _ => Err(ResolveError::NotFound {
411            name: format!(
412                "vertex near ({}, {}, {})",
413                position.x, position.y, position.z
414            ),
415        }),
416    }
417}
418
419/// Whole-component representative point: the center of the aggregate AABB of
420/// every face surface's control-point hull (plus topology vertices) across the
421/// component's solids. The hull BOUNDS the exact surfaces (convex-hull
422/// property) without touching tessellation; it overshoots curved faces, so
423/// this is a representative anchor for coincident/parallel-with-component
424/// semantics, not an exact bounding box.
425pub fn resolve_component_point(solids: &[&BrepSolid]) -> Result<SelectionGeometry, ResolveError> {
426    let mut low = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
427    let mut high = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
428    let mut any = false;
429    let mut include = |point: Vec3| {
430        low = Vec3::new(low.x.min(point.x), low.y.min(point.y), low.z.min(point.z));
431        high = Vec3::new(high.x.max(point.x), high.y.max(point.y), high.z.max(point.z));
432        any = true;
433    };
434    for solid in solids {
435        for vertex in &solid.vertices {
436            include(vertex.point);
437        }
438        for shell in &solid.shells {
439            for face in &shell.faces {
440                for row in &face.surface.control_points {
441                    for control in row {
442                        let point = control.point().map_err(|error| ResolveError::Geometry {
443                            name: "component".into(),
444                            detail: error,
445                        })?;
446                        include(point);
447                    }
448                }
449            }
450        }
451    }
452    if !any {
453        return Err(ResolveError::Geometry {
454            name: "component".into(),
455            detail: "component has no geometry to anchor".into(),
456        });
457    }
458    Ok(SelectionGeometry::Point {
459        position: low.add(high).scale(0.5),
460    })
461}
462
463// ---------------------------------------------------------------------------
464// Face resolution
465// ---------------------------------------------------------------------------
466
467fn face_label(record: &FaceRecord) -> String {
468    record
469        .name
470        .clone()
471        .unwrap_or_else(|| format!("face {}", record.id))
472}
473
474fn resolve_face_record(
475    solid: &BrepSolid,
476    record: &FaceRecord,
477) -> Result<SelectionGeometry, ResolveError> {
478    match record.surface.analytic() {
479        Some(AnalyticSurface::RuledRevolution {
480            frame, rho0, rho1, ..
481        }) => {
482            // Meridional generatrix (reconstruction-verified), so equal end
483            // radii already mean a constant radius: a genuine cylinder.
484            let span = rho0.abs().max(rho1.abs());
485            let radius = ((rho0 - rho1).abs() <= RESOLVE_TOLERANCE * (1.0 + span))
486                .then_some(0.5 * (rho0 + rho1));
487            Ok(SelectionGeometry::Axis {
488                origin: frame.origin,
489                direction: frame.axis,
490                radius,
491            })
492        }
493        Some(AnalyticSurface::Sphere { frame, radius }) => Ok(SelectionGeometry::Sphere {
494            center: frame.origin,
495            radius: *radius,
496        }),
497        Some(AnalyticSurface::Torus { frame, .. }) => Ok(SelectionGeometry::Axis {
498            origin: frame.origin,
499            direction: frame.axis,
500            radius: None,
501        }),
502        Some(AnalyticSurface::Revolution {
503            frame, generatrix, ..
504        }) => Ok(SelectionGeometry::Axis {
505            origin: frame.origin,
506            direction: frame.axis,
507            radius: revolution_cylinder_radius(frame, generatrix),
508        }),
509        // Exact plane, or unrecognized (imported) geometry that may still be
510        // planar — one shared sampled-planarity lane, `face_frame` parity.
511        Some(AnalyticSurface::Plane { .. }) | None => planar_face(solid, record),
512    }
513}
514
515/// `Some(radius)` when a general-revolution generatrix is a straight line at
516/// CONSTANT radial distance from the axis — a partial cylinder (e.g. a 180°
517/// revolve wall). Equal END radii alone are not enough: a line skew to the
518/// axis revolves into a hyperboloid whose radius dips between equal ends. But
519/// squared radial distance along a straight line is a QUADRATIC function of
520/// arc length, so three equal samples (ends + midpoint) force it constant.
521fn revolution_cylinder_radius(
522    frame: &crate::RevolutionFrame,
523    generatrix: &NurbsCurve,
524) -> Option<f64> {
525    if !control_net_is_colinear(generatrix) {
526        return None;
527    }
528    let [t0, t1] = generatrix.domain().ok()?;
529    let radial = |t: f64| -> Option<f64> {
530        let point = generatrix.evaluate(t).ok()?;
531        let delta = point.sub(frame.origin);
532        Some(delta.sub(frame.axis.scale(delta.dot(frame.axis))).length())
533    };
534    let rho0 = radial(t0)?;
535    let rho_mid = radial(0.5 * (t0 + t1))?;
536    let rho1 = radial(t1)?;
537    let span = rho0.abs().max(rho1.abs());
538    let tolerance = RESOLVE_TOLERANCE * (1.0 + span);
539    ((rho0 - rho1).abs() <= tolerance && (rho_mid - rho0).abs() <= tolerance)
540        .then_some((rho0 + rho_mid + rho1) / 3.0)
541}
542
543/// Planar-face resolution: sampled-constant-normal acceptance (the established
544/// `face_frame` planarity test, densified to a 3×3 grid), OUTWARD normal via
545/// the face sense, origin = the centre of the face boundary's bounding box
546/// taken in the face's OWN in-plane basis, projected onto the plane.
547///
548/// The basis is the surface's u-tangent at the patch midpoint (its normal
549/// component removed) and the normal's cross with it. A component pose moves
550/// the control net rigidly and that basis with it, so the box of the same
551/// boundary samples is the same box and the anchor is a FIXED point of the
552/// body. A world-axis box is not: on a face with no centre of symmetry (a
553/// triangle, an L) its centre slides over the face as the body turns — and the
554/// assembly angle gizmo, hinged on the two face anchors, slid with it on
555/// every edit (`planar_face_anchor_is_a_fixed_body_point_on_an_asymmetric_face`).
556/// For a centrally symmetric face (rectangle, disc) both rules name the same
557/// point. The tangent test is scale-free (a stretched knot domain shrinks the
558/// partials without moving the plane); the v-partial is the body-fixed
559/// fallback, and a face whose partials both vanish at the midpoint cannot
560/// reach this code — its normal would have failed above.
561fn planar_face(solid: &BrepSolid, record: &FaceRecord) -> Result<SelectionGeometry, ResolveError> {
562    let geometry = (|| -> Result<Option<SelectionGeometry>, String> {
563        let [u0, u1] = record.surface.domain_u()?;
564        let [v0, v1] = record.surface.domain_v()?;
565        let (um, vm) = ((u0 + u1) * 0.5, (v0 + v1) * 0.5);
566        let center_normal = record.surface.normal(um, vm)?;
567        for u in [u0, um, u1] {
568            for v in [v0, vm, v1] {
569                if record.surface.normal(u, v)?.dot(center_normal) < 1.0 - 1e-6 {
570                    return Ok(None);
571                }
572            }
573        }
574        let normal = if record.same_sense {
575            center_normal
576        } else {
577            center_normal.scale(-1.0)
578        }
579        .normalized()?;
580
581        // The face's own in-plane basis (body-fixed, see the doc above). The
582        // test is SCALE-FREE — the in-plane part must carry the derivative's
583        // own magnitude, whatever that magnitude is — because a knot domain
584        // can be stretched arbitrarily without moving a point of the plane,
585        // and an absolute floor would send such a face to a pose-dependent
586        // rule (`planar_anchor_is_body_fixed_whatever_the_parameter_scale`).
587        // Both partials are tried; both are body-fixed. Neither can fail for
588        // a face that got here: `normal(um, vm)` above succeeded, so
589        // |∂u × ∂v| > 1e-12 at the midpoint — both partials are non-zero —
590        // and the accepted constant normal is their cross, so each lies in
591        // the plane. The error is the unreachable branch, not a world axis.
592        let plane_point = record.surface.evaluate(um, vm)?;
593        let partials = record.surface.derivatives(um, vm, 1)?;
594        let in_plane = |partial: Vec3| -> Option<Vec3> {
595            let tangent = partial.sub(normal.scale(partial.dot(normal)));
596            let length = tangent.length();
597            (length.is_finite() && length > 1e-6 * partial.length() && length > 0.0)
598                .then(|| tangent.scale(1.0 / length))
599        };
600        let e1 = in_plane(partials[1][0])
601            .or_else(|| in_plane(partials[0][1]))
602            .ok_or_else(|| {
603                "planar face: both surface partials degenerate at the patch midpoint".to_string()
604            })?;
605        let e2 = normal.cross(e1).normalized()?;
606        // Boundary bounding box in that basis, from the face loops'
607        // non-degenerate edges; the patch midpoint for loop-less faces.
608        let mut low = [f64::INFINITY; 2];
609        let mut high = [f64::NEG_INFINITY; 2];
610        let mut any = false;
611        for loop_record in &record.loops {
612            for coedge in &loop_record.coedges {
613                let Some(edge) = solid.edges.iter().find(|edge| edge.id == coedge.edge_id) else {
614                    continue;
615                };
616                if edge.degenerate {
617                    continue;
618                }
619                for step in 0..=4 {
620                    let t = edge.t0 + (edge.t1 - edge.t0) * (step as f64 / 4.0);
621                    let offset = edge.curve.evaluate(t)?.sub(plane_point);
622                    let (a, b) = (offset.dot(e1), offset.dot(e2));
623                    low = [low[0].min(a), low[1].min(b)];
624                    high = [high[0].max(a), high[1].max(b)];
625                    any = true;
626                }
627            }
628        }
629        let center = if any {
630            plane_point
631                .add(e1.scale((low[0] + high[0]) * 0.5))
632                .add(e2.scale((low[1] + high[1]) * 0.5))
633        } else {
634            plane_point
635        };
636        // Project onto the plane so the origin lies exactly on it (the basis
637        // is in-plane; this clears rounding).
638        let signed = center.sub(plane_point).dot(normal);
639        let origin = center.sub(normal.scale(signed));
640        Ok(Some(SelectionGeometry::Plane { origin, normal }))
641    })();
642    match geometry {
643        Ok(Some(frame)) => Ok(frame),
644        Ok(None) => Err(ResolveError::Unsupported {
645            name: face_label(record),
646            detail: "face carries no analytic frame (freeform surface)".into(),
647        }),
648        Err(error) => Err(ResolveError::Geometry {
649            name: face_label(record),
650            detail: error,
651        }),
652    }
653}
654
655// ---------------------------------------------------------------------------
656// Edge resolution
657// ---------------------------------------------------------------------------
658
659fn edge_label(record: &EdgeRecord) -> String {
660    record
661        .name
662        .clone()
663        .unwrap_or_else(|| format!("edge {}", record.id))
664}
665
666fn resolve_edge_record(record: &EdgeRecord) -> Result<SelectionGeometry, ResolveError> {
667    if record.degenerate {
668        return Err(ResolveError::Unsupported {
669            name: edge_label(record),
670            detail: "degenerate edge".into(),
671        });
672    }
673    match edge_geometry(record) {
674        Ok(Some(frame)) => Ok(frame),
675        Ok(None) => Err(ResolveError::Unsupported {
676            name: edge_label(record),
677            detail: "edge is neither straight nor circular".into(),
678        }),
679        Err(error) => Err(ResolveError::Geometry {
680            name: edge_label(record),
681            detail: error,
682        }),
683    }
684}
685
686fn edge_geometry(record: &EdgeRecord) -> Result<Option<SelectionGeometry>, String> {
687    if let Some(line) = straight_edge_line(record)? {
688        return Ok(Some(line));
689    }
690    circular_edge_circle(record)
691}
692
693/// The whole curve's extent, for scale-relative acceptance.
694fn control_net_extent(curve: &NurbsCurve) -> f64 {
695    let mut extent = 0.0_f64;
696    for control in &curve.control_points {
697        if let Ok(point) = control.point() {
698            extent = extent
699                .max(point.x.abs())
700                .max(point.y.abs())
701                .max(point.z.abs());
702        }
703    }
704    extent
705}
706
707/// A colinear control net is EXACT proof of a straight curve (convex-hull
708/// property), and every straight curve this kernel builds — `make_line`
709/// products through any split/degree change — keeps its net colinear.
710fn control_net_is_colinear(curve: &NurbsCurve) -> bool {
711    let points: Vec<Vec3> = curve
712        .control_points
713        .iter()
714        .filter_map(|control| control.point().ok())
715        .collect();
716    let Some((&first, rest)) = points.split_first() else {
717        return false;
718    };
719    let tolerance = RESOLVE_TOLERANCE * (1.0 + control_net_extent(curve));
720    let Some(direction) = rest
721        .iter()
722        .map(|point| point.sub(first))
723        .max_by(|a, b| a.length().total_cmp(&b.length()))
724        .and_then(|chord| chord.normalized().ok())
725    else {
726        return false;
727    };
728    points.iter().all(|point| {
729        let offset = point.sub(first);
730        offset.sub(direction.scale(offset.dot(direction))).length() <= tolerance
731    })
732}
733
734/// Straight edge → its infinite carrier line, oriented start→end over the
735/// trimmed span, origin at the chord midpoint.
736fn straight_edge_line(record: &EdgeRecord) -> Result<Option<SelectionGeometry>, String> {
737    if !control_net_is_colinear(&record.curve) {
738        return Ok(None);
739    }
740    let start = record.curve.evaluate(record.t0)?;
741    let end = record.curve.evaluate(record.t1)?;
742    let chord = end.sub(start);
743    if chord.length() <= RESOLVE_TOLERANCE * (1.0 + control_net_extent(&record.curve)) {
744        // Zero-length trim of a straight curve — nothing to orient by.
745        return Ok(None);
746    }
747    Ok(Some(SelectionGeometry::Line {
748        origin: start.add(end).scale(0.5),
749        direction: chord.normalized()?,
750    }))
751}
752
753/// Circular/arc edge → center + axis + radius: a candidate circle from three
754/// interior samples (distinct even on a closed full circle), then VERIFIED at
755/// [`CIRCLE_VERIFY_SAMPLES`] points — equidistant from the center and coplanar
756/// — so ellipses, splines, and helices fail cleanly while exact kernel arcs
757/// pass at machine precision.
758fn circular_edge_circle(record: &EdgeRecord) -> Result<Option<SelectionGeometry>, String> {
759    let span = record.t1 - record.t0;
760    if !(span > 0.0) {
761        return Ok(None);
762    }
763    let at = |fraction: f64| record.curve.evaluate(record.t0 + span * fraction);
764    let (a, b, c) = (at(1.0 / 6.0)?, at(0.5)?, at(5.0 / 6.0)?);
765    let Some(center) = crate::analytic_surface::circumcenter(a, b, c) else {
766        return Ok(None);
767    };
768    // Right-hand rule along the increasing-parameter direction.
769    let Ok(axis) = b.sub(a).cross(c.sub(b)).normalized() else {
770        return Ok(None);
771    };
772    let radius = a.sub(center).length();
773    let tolerance = RESOLVE_TOLERANCE * (1.0 + control_net_extent(&record.curve));
774    for step in 0..=CIRCLE_VERIFY_SAMPLES {
775        let point = at(step as f64 / CIRCLE_VERIFY_SAMPLES as f64)?;
776        let delta = point.sub(center);
777        if (delta.length() - radius).abs() > tolerance || delta.dot(axis).abs() > tolerance {
778            return Ok(None);
779        }
780    }
781    Ok(Some(SelectionGeometry::Circle {
782        center,
783        axis,
784        radius,
785    }))
786}
787
788// ---------------------------------------------------------------------------
789// Tests
790// ---------------------------------------------------------------------------
791