Skip to main content

brep_kernel/solvers/
assembly_resolve.rs

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