Skip to main content

cadmpeg_codec_nx/
decode.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Build IR and diagnostics from an NX SPLMSSTR container.
3//!
4//! [`scan`] parses the container and inflates its embedded streams. [`decode`]
5//! converts supported analytic and NURBS carriers to millimetres, resolves
6//! supported topology, preserves each Parasolid stream as an unknown record, and
7//! returns a [`DecodeReport`] describing incomplete transfer. Partition and
8//! deltas streams are both decoded; callers must use the report to account for
9//! unresolved active-face selection and other loss.
10//!
11//! [`DecodeReport`]: cadmpeg_ir::report::DecodeReport
12
13use std::collections::{BTreeMap, BTreeSet};
14
15use cadmpeg_ir::codec::{CodecError, DecodeResult};
16use cadmpeg_ir::decode::{DecodeContext, View};
17use cadmpeg_ir::document::{CadIr, SourceMeta};
18use cadmpeg_ir::eval::{
19    analytic_surface_parameters, curve_point, model_surface_point_by_id, nurbs_surface_partials,
20    pcurve_uv, surface_point,
21};
22use cadmpeg_ir::features::{
23    BodyRetentionMode, BodySelection, BodyTrimSide, BooleanOp, ChamferSpec,
24    CurveProjectionDirection, CurveProjectionDirectionState, EdgeSelection, ExtrudeExtent,
25    FaceSelection, FeatureDefinition, HoleKind, Length, ParameterId, PathRef, PatternKind,
26    ProfileRef, RadiusSpec, RibConstruction, RibDraft, SketchSpace, Termination, TrimRegion,
27};
28use cadmpeg_ir::geometry::{
29    BlendCrossSection, BlendRadiusLaw, BlendSupport, Curve, CurveGeometry, IntcurveSupportContext,
30    IntcurveSupportSide, NurbsCurve, NurbsSurface, Pcurve, PcurveGeometry, ProceduralCurve,
31    ProceduralCurveDefinition, ProceduralSurface, ProceduralSurfaceDefinition, Surface,
32    SurfaceCurveFamily, SurfaceGeometry,
33};
34use cadmpeg_ir::hash::sha256_hex;
35use cadmpeg_ir::ids::{
36    BodyId, CoedgeId, CurveId, EdgeId, FaceId, LoopId, PcurveId, PointId, ProceduralCurveId,
37    ProceduralSurfaceId, RegionId, ShellId, SurfaceId, UnknownId, VertexId,
38};
39use cadmpeg_ir::math::{Point2, Point3, Vector3};
40use cadmpeg_ir::report::{DecodeReport, LossCategory, LossCode, LossNote, Severity};
41use cadmpeg_ir::topology::{
42    Body, BodyKind, Coedge, Edge, Face, Loop, Point, Region, Sense, Shell, Vertex,
43};
44use cadmpeg_ir::units::Units;
45use cadmpeg_ir::unknown::UnknownRecord;
46use cadmpeg_ir::{AnnotationBuilder, Exactness, SourceObjectAssociation};
47
48use crate::container::{self, Container};
49use crate::geometry;
50use crate::native::vector::{cross_vector, dot_vector, unit_vector};
51use crate::parasolid::{self, Stream, StreamKind};
52use crate::topology::{Graph, Node};
53
54pub(crate) const MISSING_TOLERANCE: f64 = -31_415_800_000_000.0;
55/// Parsed container data shared by inspection and entity decoding.
56pub struct Scan {
57    /// Parsed SPLMSSTR container.
58    pub container: Container,
59    /// Located and inflated Parasolid or preview streams.
60    pub streams: Vec<Stream>,
61}
62
63impl Scan {
64    /// Count streams with the requested classification.
65    pub fn count(&self, kind: StreamKind) -> usize {
66        self.streams.iter().filter(|s| s.kind == kind).count()
67    }
68
69    /// Return whether the file contains an inline Parasolid stream.
70    ///
71    /// NX assemblies may contain only references to external child parts.
72    pub fn has_parasolid(&self) -> bool {
73        self.streams.iter().any(|s| s.kind.is_parasolid())
74    }
75}
76
77/// Parse the SPLMSSTR container and inflate streams in its canonical part entry.
78pub fn scan<'a>(ctx: &DecodeContext<'a>, root: View<'a>) -> Result<Scan, CodecError> {
79    let container = container::scan_bytes(root.window().to_vec())?;
80    let streams = parasolid::extract_streams(ctx, root, &container)?;
81    Ok(Scan { container, streams })
82}
83
84/// Decode an NX `.prt` into IR and a loss report.
85///
86/// When [`DecodeContext::container_only`] is set, the returned IR contains source
87/// metadata and preserved streams but no typed entities. Otherwise the decoder
88/// emits supported geometry and resolvable topology. A valid container can
89/// decode successfully with no geometry, including an assembly whose geometry
90/// resides in external child parts.
91pub fn decode<'a>(ctx: &DecodeContext<'a>, root: View<'a>) -> Result<DecodeResult, CodecError> {
92    let scan = scan(ctx, root)?;
93
94    if ctx.container_only() {
95        let (ir, annotations, unknowns) = build_metadata_ir(&scan)?;
96        let mut report = build_container_report(&scan, true);
97        report_untransferred_streams(&scan, &mut report);
98        return decode_result(ir, report, annotations, &unknowns);
99    }
100
101    if let Some((ir, report, annotations, unknowns)) = try_decode_geometry(&scan) {
102        return decode_result(ir, report, annotations, &unknowns);
103    }
104
105    let (ir, annotations, unknowns) = build_metadata_ir(&scan)?;
106    let mut report = build_container_report(&scan, false);
107    report_untransferred_streams(&scan, &mut report);
108    decode_result(ir, report, annotations, &unknowns)
109}
110
111fn decode_result(
112    mut ir: CadIr,
113    report: DecodeReport,
114    annotations: cadmpeg_ir::Annotations,
115    unknowns: &[UnknownRecord],
116) -> Result<DecodeResult, CodecError> {
117    let mut source_fidelity = cadmpeg_ir::SourceFidelity {
118        annotations,
119        ..cadmpeg_ir::SourceFidelity::default()
120    };
121    source_fidelity.attach_native_unknown_records(&mut ir, "nx", unknowns)?;
122    Ok(DecodeResult::with_source_fidelity(
123        ir,
124        report,
125        source_fidelity,
126    ))
127}
128
129fn report_untransferred_streams(scan: &Scan, report: &mut DecodeReport) {
130    for (index, stream) in scan.streams.iter().enumerate() {
131        if !stream.kind.is_parasolid() {
132            report.losses.push(LossNote {
133                code: LossCode::PassthroughRecordOmitted,
134                category: LossCategory::Other,
135                severity: Severity::Info,
136                message: format!(
137                    "Non-Parasolid {} stream #{index} was classified but not transferred.",
138                    stream.kind.label()
139                ),
140                provenance: None,
141            });
142        }
143    }
144}
145
146/// Aggregate carrier counts across the decoded streams, for reporting.
147#[derive(Debug, Default)]
148struct Counts {
149    points: usize,
150    planes: usize,
151    cylinders: usize,
152    cones: usize,
153    spheres: usize,
154    tori: usize,
155    nurbs_surfaces: usize,
156    offset_surfaces: usize,
157    blend_surfaces: usize,
158    lines: usize,
159    circles: usize,
160    ellipses: usize,
161    nurbs_curves: usize,
162    intersection_curves: usize,
163    intersection_rejections: crate::intersection::RejectionCounts,
164}
165
166impl Counts {
167    fn surfaces(&self) -> usize {
168        self.planes
169            + self.cylinders
170            + self.cones
171            + self.spheres
172            + self.tori
173            + self.nurbs_surfaces
174            + self.offset_surfaces
175            + self.blend_surfaces
176    }
177    fn curves(&self) -> usize {
178        self.lines + self.circles + self.ellipses + self.nurbs_curves + self.intersection_curves
179    }
180}
181
182pub(crate) fn ordered_point_candidates<'a>(
183    stream: &[u8],
184    graph: &'a Graph,
185) -> Vec<(usize, Point3, Option<&'a Node>)> {
186    ordered_fixed_candidates(
187        geometry::points(stream)
188            .into_iter()
189            .map(|point| (point.pos, point.position)),
190        graph,
191        29..=29,
192        Node::point_position,
193    )
194}
195
196pub(crate) fn ordered_surface_candidates<'a>(
197    stream: &[u8],
198    graph: &'a Graph,
199) -> Vec<(usize, SurfaceGeometry, Option<&'a Node>)> {
200    ordered_fixed_candidates(
201        geometry::surfaces(stream)
202            .into_iter()
203            .map(|surface| (surface.pos, surface.geometry)),
204        graph,
205        50..=54,
206        Node::surface_geometry,
207    )
208}
209
210pub(crate) fn ordered_curve_candidates<'a>(
211    stream: &[u8],
212    graph: &'a Graph,
213) -> Vec<(usize, CurveGeometry, Option<&'a Node>)> {
214    ordered_fixed_candidates(
215        geometry::curves(stream)
216            .into_iter()
217            .map(|curve| (curve.pos, curve.geometry)),
218        graph,
219        30..=32,
220        Node::curve_geometry,
221    )
222}
223
224fn ordered_fixed_candidates<T>(
225    fallback: impl IntoIterator<Item = (usize, T)>,
226    graph: &Graph,
227    kinds: std::ops::RangeInclusive<u8>,
228    graph_value: impl Fn(&Node) -> Option<T>,
229) -> Vec<(usize, T, Option<&Node>)> {
230    let mut candidates = BTreeMap::new();
231    for (offset, value) in fallback {
232        let node = graph
233            .at_pos(offset)
234            .filter(|node| graph_value(node).is_some());
235        candidates.insert(offset, (value, node));
236    }
237    for node in kinds.flat_map(|kind| graph.of_kind(kind)) {
238        if let Some(value) = graph_value(node) {
239            candidates.insert(node.pos, (value, Some(node)));
240        }
241    }
242    candidates
243        .into_iter()
244        .map(|(offset, (value, node))| (offset, value, node))
245        .collect()
246}
247
248/// Decode analytic carriers from every Parasolid stream. Returns `None` when no
249/// carrier of any kind passes its gate, so the caller falls back to metadata.
250fn try_decode_geometry(
251    scan: &Scan,
252) -> Option<(
253    CadIr,
254    DecodeReport,
255    cadmpeg_ir::Annotations,
256    Vec<UnknownRecord>,
257)> {
258    let mut ir = CadIr::empty(Units::default());
259    let mut annotations = AnnotationBuilder::new();
260    let mut unknowns = Vec::new();
261    ir.source = Some(source_meta(scan));
262    let mut counts = Counts::default();
263    let mut body_node_ids = BTreeMap::new();
264    let parsed = crate::native::ParsedStreams::parse(scan);
265
266    for (si, stream) in scan.streams.iter().enumerate() {
267        if !stream.kind.is_parasolid() {
268            continue;
269        }
270        let view = parsed.stream(si).view_for_geometry();
271        let semantic = parsed.semantic_bytes(si);
272        let stream_name = format!("parasolid#{si}:{}", stream.kind.label());
273        let source_stream = annotations.stream(format!("nx:{stream_name}"));
274        let graph = &view.graph;
275        body_node_ids.extend(topology_body_node_ids(si, graph));
276        let mut points_by_xmt = BTreeMap::new();
277        let mut surfaces_by_xmt = BTreeMap::new();
278        let mut curves_by_xmt = BTreeMap::new();
279        let mut pcurves_by_xmt = BTreeMap::new();
280        let mut pcurve_supports_by_xmt = BTreeMap::new();
281        let mut trim_ranges = BTreeMap::new();
282        let mut pending_blend_supports = Vec::new();
283        let mut pending_blend_spines = Vec::new();
284        let mut pending_ext11_support_uv = Vec::new();
285        let first_surface = ir.model.surfaces.len();
286        let first_curve = ir.model.curves.len();
287        for (pi, (position_offset, position, node)) in ordered_point_candidates(semantic, graph)
288            .into_iter()
289            .enumerate()
290        {
291            let pid = PointId(format!("nx:s{si}:pt#{pi}"));
292            let vid = VertexId(format!("nx:s{si}:v#{pi}"));
293            if let Some(node) = node {
294                annotate_node(&mut annotations, &pid, source_stream, node, "POINT");
295            } else {
296                annotations
297                    .note(&pid, source_stream, position_offset as u64)
298                    .tag("POINT");
299            }
300            annotations.derived(&pid, "position");
301            ir.model.points.push(Point {
302                id: pid.clone(),
303                position,
304                source_object: None,
305            });
306            ir.model.vertices.push(Vertex {
307                id: vid.clone(),
308                point: pid.clone(),
309                tolerance: None,
310            });
311            if let Some(node) = node {
312                points_by_xmt.insert(node.xmt, pid);
313            }
314            counts.points += 1;
315        }
316
317        for (fi, (offset, geometry, node)) in ordered_surface_candidates(semantic, graph)
318            .into_iter()
319            .enumerate()
320        {
321            match &geometry {
322                SurfaceGeometry::Plane { .. } => counts.planes += 1,
323                SurfaceGeometry::Cylinder { .. } => counts.cylinders += 1,
324                SurfaceGeometry::Cone { .. } => counts.cones += 1,
325                SurfaceGeometry::Sphere { .. } => counts.spheres += 1,
326                SurfaceGeometry::Torus { .. } => counts.tori += 1,
327                SurfaceGeometry::Nurbs(_)
328                | SurfaceGeometry::Procedural { .. }
329                | SurfaceGeometry::Polygonal { .. }
330                | SurfaceGeometry::Transformed { .. }
331                | SurfaceGeometry::Unknown { .. } => {}
332            }
333            let id = SurfaceId(format!("nx:s{si}:surf#{fi}"));
334            if let Some(node) = node {
335                annotate_node(
336                    &mut annotations,
337                    &id,
338                    source_stream,
339                    node,
340                    surface_tag(&geometry),
341                );
342            } else {
343                annotations
344                    .note(&id, source_stream, offset as u64)
345                    .tag(surface_tag(&geometry));
346            }
347            annotations.derived(&id, "geometry");
348            ir.model.surfaces.push(Surface {
349                id: id.clone(),
350                geometry,
351                source_object: None,
352            });
353            if let Some(node) = node {
354                surfaces_by_xmt.insert(node.xmt, id);
355            }
356        }
357
358        for (fi, surf) in crate::nurbs::surfaces(semantic).into_iter().enumerate() {
359            counts.nurbs_surfaces += 1;
360            let id = SurfaceId(format!("nx:s{si}:nurbs-surf#{fi}"));
361            annotations
362                .note(&id, source_stream, surf.pos as u64)
363                .tag("B_SPLINE_SURFACE");
364            annotations.derived(&id, "geometry");
365            ir.model.surfaces.push(Surface {
366                id: id.clone(),
367                geometry: surf.geometry,
368                source_object: None,
369            });
370            if let Some(node) = graph.at_pos(surf.pos) {
371                surfaces_by_xmt.insert(node.xmt, id);
372            }
373        }
374
375        for (oi, offset) in view.offset_surfaces.iter().copied().enumerate() {
376            let Some(support) = surfaces_by_xmt.get(&offset.support).cloned() else {
377                continue;
378            };
379            let surface_id = SurfaceId(format!("nx:s{si}:offset-surf#{oi}"));
380            let procedural_id = ProceduralSurfaceId(format!("nx:s{si}:offset#{oi}"));
381            annotations
382                .note(&surface_id, source_stream, offset.pos as u64)
383                .tag("OFFSET_SURF");
384            annotations.derived(&surface_id, "geometry");
385            ir.model.surfaces.push(Surface {
386                id: surface_id.clone(),
387                geometry: SurfaceGeometry::Procedural {
388                    construction: procedural_id.clone(),
389                },
390                source_object: Some(SourceObjectAssociation {
391                    format: "nx".into(),
392                    object_id: format!("nx:s{si}:offset-surface-record#{}", offset.xmt),
393                    name: None,
394                    color: None,
395                    visible: None,
396                    layer: None,
397                    instance_path: Vec::new(),
398                }),
399            });
400            annotations
401                .note(&procedural_id, source_stream, offset.pos as u64)
402                .tag("OFFSET_SURF");
403            annotations.derived(&procedural_id, "definition");
404            ir.model.procedural_surfaces.push(ProceduralSurface {
405                id: procedural_id,
406                surface: surface_id.clone(),
407                definition: ProceduralSurfaceDefinition::Offset {
408                    support,
409                    distance: offset.distance,
410                    u_sense: Some(0),
411                    v_sense: Some(0),
412                    extension_flags: Vec::new(),
413                    revision_form: None,
414                },
415                cache_fit_tolerance: None,
416                record_bounds: None,
417            });
418            surfaces_by_xmt.insert(offset.xmt, surface_id);
419            counts.offset_surfaces += 1;
420        }
421
422        for (bi, blend) in view.blend_surfaces.iter().copied().enumerate() {
423            let surface_id = SurfaceId(format!("nx:s{si}:blend-surf#{bi}"));
424            let procedural_id = ProceduralSurfaceId(format!("nx:s{si}:blend#{bi}"));
425            annotations
426                .note(&surface_id, source_stream, blend.pos as u64)
427                .tag("BLEND_SURF");
428            annotations.derived(&surface_id, "geometry");
429            ir.model.surfaces.push(Surface {
430                id: surface_id.clone(),
431                geometry: SurfaceGeometry::Procedural {
432                    construction: procedural_id.clone(),
433                },
434                source_object: Some(SourceObjectAssociation {
435                    format: "nx".to_string(),
436                    object_id: format!("nx:s{si}:blend-surface-record#{}", blend.xmt),
437                    name: None,
438                    color: None,
439                    visible: None,
440                    layer: None,
441                    instance_path: Vec::new(),
442                }),
443            });
444            annotations
445                .note(&procedural_id, source_stream, blend.pos as u64)
446                .tag("BLEND_SURF");
447            annotations.derived(&procedural_id, "definition");
448            let procedural_index = ir.model.procedural_surfaces.len();
449            ir.model.procedural_surfaces.push(ProceduralSurface {
450                id: procedural_id,
451                surface: surface_id.clone(),
452                definition: ProceduralSurfaceDefinition::Blend {
453                    supports: [None, None],
454                    spine: None,
455                    radius: BlendRadiusLaw::Constant {
456                        signed_radius: blend.offsets[0],
457                    },
458                    cross_section: BlendCrossSection::Circular,
459                    native: None,
460                },
461                cache_fit_tolerance: None,
462                record_bounds: None,
463            });
464            pending_blend_supports.push((procedural_index, blend.supports, blend.offsets));
465            if blend.spine > 1 {
466                pending_blend_spines.push((procedural_index, blend.spine));
467            }
468            surfaces_by_xmt.insert(blend.xmt, surface_id);
469            counts.blend_surfaces += 1;
470        }
471
472        for (procedural_index, support_xmts, offsets) in pending_blend_supports {
473            let supports = [0, 1].map(|side| {
474                surfaces_by_xmt
475                    .get(&support_xmts[side])
476                    .cloned()
477                    .map(|surface| BlendSupport {
478                        surface,
479                        reversed: offsets[side].is_sign_negative(),
480                    })
481            });
482            let Some(ProceduralSurface {
483                definition:
484                    ProceduralSurfaceDefinition::Blend {
485                        supports: slots, ..
486                    },
487                ..
488            }) = ir.model.procedural_surfaces.get_mut(procedural_index)
489            else {
490                continue;
491            };
492            *slots = supports;
493        }
494
495        for (ci, (offset, geometry, node)) in ordered_curve_candidates(semantic, graph)
496            .into_iter()
497            .enumerate()
498        {
499            match &geometry {
500                CurveGeometry::Line { .. } => counts.lines += 1,
501                CurveGeometry::Circle { .. } => counts.circles += 1,
502                CurveGeometry::Ellipse { .. } => counts.ellipses += 1,
503                CurveGeometry::Parabola { .. }
504                | CurveGeometry::Hyperbola { .. }
505                | CurveGeometry::Degenerate { .. }
506                | CurveGeometry::Composite { .. }
507                | CurveGeometry::Nurbs(_)
508                | CurveGeometry::Procedural { .. }
509                | CurveGeometry::Polyline { .. }
510                | CurveGeometry::Transformed { .. }
511                | CurveGeometry::Unknown { .. } => {}
512            }
513            let id = CurveId(format!("nx:s{si}:crv#{ci}"));
514            if let Some(node) = node {
515                annotate_node(
516                    &mut annotations,
517                    &id,
518                    source_stream,
519                    node,
520                    curve_tag(&geometry),
521                );
522            } else {
523                annotations
524                    .note(&id, source_stream, offset as u64)
525                    .tag(curve_tag(&geometry));
526            }
527            annotations.derived(&id, "geometry");
528            ir.model.curves.push(Curve {
529                id: id.clone(),
530                geometry,
531                source_object: None,
532            });
533            if let Some(node) = node {
534                curves_by_xmt.insert(node.xmt, id);
535            }
536        }
537
538        for (ci, crv) in crate::nurbs::curves(semantic).into_iter().enumerate() {
539            counts.nurbs_curves += 1;
540            let id = CurveId(format!("nx:s{si}:nurbs-crv#{ci}"));
541            annotations
542                .note(&id, source_stream, crv.pos as u64)
543                .tag("B_SPLINE_CURVE");
544            annotations.derived(&id, "geometry");
545            ir.model.curves.push(Curve {
546                id: id.clone(),
547                geometry: crv.geometry,
548                source_object: None,
549            });
550            if let Some(node) = graph.at_pos(crv.pos) {
551                curves_by_xmt.insert(node.xmt, id);
552            }
553        }
554
555        for (pi, pcurve) in crate::nurbs::pcurves(semantic).into_iter().enumerate() {
556            let id = PcurveId(format!("nx:s{si}:pcurve#{pi}"));
557            annotations
558                .note(&id, source_stream, pcurve.pos as u64)
559                .tag("B_CURVE_2D");
560            annotations.derived(&id, "geometry");
561            ir.model.pcurves.push(Pcurve {
562                id: id.clone(),
563                geometry: pcurve.geometry,
564                wrapper_reversed: None,
565                native_tail_flags: None,
566                parameter_range: None,
567                fit_tolerance: None,
568            });
569            if let Some(node) = graph.at_pos(pcurve.pos) {
570                pcurves_by_xmt.insert(node.xmt, id);
571            }
572        }
573
574        let intersection_scan = view.intersections.clone();
575        counts
576            .intersection_rejections
577            .extend(intersection_scan.rejected);
578        let intersection_constructions = intersection_scan.constructions;
579        let charted_intersections: BTreeMap<_, _> = intersection_scan
580            .curves
581            .into_iter()
582            .map(|curve| (curve.xmt, curve))
583            .collect();
584        for (ci, construction) in intersection_constructions.into_iter().enumerate() {
585            let curve_id = CurveId(format!("nx:s{si}:intersection-crv#{ci}"));
586            let procedural_id = ProceduralCurveId(format!("nx:s{si}:intersection#{ci}"));
587            let unknown_id = UnknownId(format!("nx:container:parasolid#{si}"));
588            let charted = charted_intersections.get(&construction.xmt);
589            if let Some(charted) = charted {
590                pending_ext11_support_uv.push((
591                    procedural_id.clone(),
592                    charted.points.clone(),
593                    charted.parameters.clone(),
594                    charted.fit_tolerance,
595                    charted.ext_support_uv.clone(),
596                ));
597            }
598            annotations
599                .note(&curve_id, source_stream, construction.pos as u64)
600                .tag("INTERSECTION");
601            if charted.is_some() {
602                annotations.derived(&curve_id, "geometry");
603            } else {
604                annotations.exactness(&curve_id, Exactness::Unknown);
605            }
606            ir.model.curves.push(Curve {
607                id: curve_id.clone(),
608                geometry: charted.map_or_else(
609                    || CurveGeometry::Unknown {
610                        record: Some(unknown_id.clone()),
611                    },
612                    |charted| {
613                        CurveGeometry::Nurbs(NurbsCurve {
614                            degree: 1,
615                            knots: linear_knots(&charted.parameters),
616                            control_points: charted.points.clone(),
617                            weights: None,
618                            periodic: false,
619                        })
620                    },
621                ),
622                source_object: Some(SourceObjectAssociation {
623                    format: "nx".into(),
624                    object_id: format!("nx:s{si}:intersection-record#{}", construction.xmt),
625                    name: None,
626                    color: None,
627                    visible: None,
628                    layer: None,
629                    instance_path: Vec::new(),
630                }),
631            });
632            annotations
633                .note(&procedural_id, source_stream, construction.pos as u64)
634                .tag("INTERSECTION");
635            if charted.is_some() {
636                annotations.derived(&procedural_id, "definition");
637            } else {
638                annotations.exactness(&procedural_id, Exactness::Unknown);
639            }
640            ir.model.procedural_curves.push(ProceduralCurve {
641                id: procedural_id,
642                curve: curve_id.clone(),
643                definition: charted.map_or_else(
644                    || ProceduralCurveDefinition::Unknown {
645                        native_kind: Some("nx:intersection".into()),
646                        record: Some(unknown_id),
647                    },
648                    |charted| {
649                        let mut support_uv = charted.support_uv.clone();
650                        if let Some(ext_support_uv) = assign_ext11_support_uv(
651                            &ir,
652                            &surfaces_by_xmt,
653                            charted.supports,
654                            &charted.points,
655                            charted.fit_tolerance,
656                            &charted.ext_support_uv,
657                        ) {
658                            for side in 0..2 {
659                                if support_uv[side].is_none() {
660                                    support_uv[side].clone_from(&ext_support_uv[side]);
661                                }
662                            }
663                        }
664                        let first = intersection_side(
665                            &ir,
666                            &surfaces_by_xmt,
667                            charted.supports[0],
668                            support_uv[0]
669                                .as_deref()
670                                .filter(|uv| uv.len() == charted.parameters.len())
671                                .map(|uv| (uv, charted.parameters.as_slice())),
672                        );
673                        let second = intersection_side(
674                            &ir,
675                            &surfaces_by_xmt,
676                            charted.supports[1],
677                            support_uv[1]
678                                .as_deref()
679                                .filter(|uv| uv.len() == charted.parameters.len())
680                                .map(|uv| (uv, charted.parameters.as_slice())),
681                        );
682                        ProceduralCurveDefinition::Intersection {
683                            context: IntcurveSupportContext {
684                                sides: [first, second],
685                                parameter_range: [
686                                    charted.parameters[0],
687                                    *charted
688                                        .parameters
689                                        .last()
690                                        .expect("validated chart has points"),
691                                ],
692                                discontinuities: [Vec::new(), Vec::new(), Vec::new()],
693                            },
694                            discontinuity_flag: false,
695                        }
696                    },
697                ),
698                cache_fit_tolerance: charted.map(|charted| charted.fit_tolerance),
699            });
700            curves_by_xmt.insert(construction.xmt, curve_id);
701            counts.intersection_curves += 1;
702        }
703
704        for (procedural_index, spine_xmt) in pending_blend_spines {
705            let Some(spine) = curves_by_xmt.get(&spine_xmt).cloned() else {
706                continue;
707            };
708            let Some(ProceduralSurface {
709                definition: ProceduralSurfaceDefinition::Blend { spine: slot, .. },
710                ..
711            }) = ir.model.procedural_surfaces.get_mut(procedural_index)
712            else {
713                continue;
714            };
715            *slot = Some(spine);
716        }
717
718        let trimmed_curves = &view.trimmed_curves;
719        let mut normalized_pcurves = BTreeSet::new();
720        let surface_curves = &view.surface_curves;
721        loop {
722            let mapped = curves_by_xmt.len() + pcurves_by_xmt.len() + pcurve_supports_by_xmt.len();
723            for trim in trimmed_curves {
724                if let Some(basis) = curves_by_xmt.get(&trim.basis).cloned() {
725                    let parameters = canonical_trim_range(&ir, &basis, trim.parameters);
726                    curves_by_xmt.insert(trim.xmt, basis);
727                    if let Some(parameters) = parameters {
728                        trim_ranges.insert(trim.xmt, parameters);
729                    }
730                }
731                if let Some(pcurve) = pcurves_by_xmt.get(&trim.basis).cloned() {
732                    if let Some(carrier) = ir.model.pcurves.iter_mut().find(|p| p.id == pcurve) {
733                        carrier.parameter_range = Some(trim.parameters);
734                    }
735                    pcurves_by_xmt.insert(trim.xmt, pcurve);
736                    if let Some(support) = pcurve_supports_by_xmt.get(&trim.basis).cloned() {
737                        pcurve_supports_by_xmt.insert(trim.xmt, support);
738                    }
739                    trim_ranges.insert(trim.xmt, trim.parameters);
740                }
741            }
742            for surface_curve in surface_curves {
743                if let Some(pcurve) = pcurves_by_xmt.get(&surface_curve.pcurve).cloned() {
744                    if !normalized_pcurves.contains(&pcurve) {
745                        let support = surfaces_by_xmt
746                            .get(&surface_curve.surface)
747                            .and_then(|id| {
748                                ir.model.surfaces.iter().find(|surface| surface.id == *id)
749                            })
750                            .map(|surface| surface.geometry.clone());
751                        let normalized = if let (Some(support), Some(carrier)) = (
752                            support,
753                            ir.model
754                                .pcurves
755                                .iter_mut()
756                                .find(|candidate| candidate.id == pcurve),
757                        ) {
758                            normalize_pcurve_parameters(&mut carrier.geometry, &support).is_some()
759                        } else {
760                            false
761                        };
762                        if !normalized {
763                            pcurves_by_xmt.remove(&surface_curve.pcurve);
764                            ir.model.pcurves.retain(|candidate| candidate.id != pcurve);
765                            continue;
766                        }
767                        normalized_pcurves.insert(pcurve.clone());
768                    }
769                    if let Some(carrier) = ir.model.pcurves.iter_mut().find(|p| p.id == pcurve) {
770                        carrier.fit_tolerance = decoded_tolerance(surface_curve.tolerance);
771                    }
772                    pcurves_by_xmt.insert(surface_curve.xmt, pcurve);
773                    if let Some(support) = surfaces_by_xmt.get(&surface_curve.surface).cloned() {
774                        pcurve_supports_by_xmt.insert(surface_curve.xmt, support);
775                    }
776                }
777                if let Some(original) = curves_by_xmt.get(&surface_curve.original).cloned() {
778                    curves_by_xmt.insert(surface_curve.xmt, original);
779                }
780            }
781            if curves_by_xmt.len() + pcurves_by_xmt.len() + pcurve_supports_by_xmt.len() == mapped {
782                break;
783            }
784        }
785
786        retain_unresolved_topology_carriers(
787            &mut ir,
788            si,
789            graph,
790            &mut surfaces_by_xmt,
791            &mut curves_by_xmt,
792            &pcurves_by_xmt,
793            source_stream,
794            &mut annotations,
795        );
796
797        emit_topology(
798            &mut ir,
799            si,
800            graph,
801            &points_by_xmt,
802            &surfaces_by_xmt,
803            &curves_by_xmt,
804            &pcurves_by_xmt,
805            &pcurve_supports_by_xmt,
806            &trim_ranges,
807            source_stream,
808            &mut annotations,
809        );
810        complete_ext11_support_uv(&mut ir, &pending_ext11_support_uv);
811        complete_parameterization_equivalent_support_uv(&mut ir);
812        complete_support_uv(&mut ir, &pending_ext11_support_uv);
813        attach_completed_intersection_pcurves(
814            &mut ir,
815            graph,
816            &format!("nx:s{si}"),
817            source_stream,
818            &mut annotations,
819        );
820
821        // Preserve the whole inflated stream verbatim so nothing is dropped.
822        let mut unknown = unknown_stream(si, stream);
823        unknown.links.extend(
824            ir.model.surfaces[first_surface..]
825                .iter()
826                .map(|surface| surface.id.0.clone()),
827        );
828        unknown.links.extend(
829            ir.model.curves[first_curve..]
830                .iter()
831                .map(|curve| curve.id.0.clone()),
832        );
833        let container_stream = annotations.stream("nx:container");
834        annotations
835            .note(&unknown.id, container_stream, stream.file_offset as u64)
836            .tag(stream.kind.label());
837        annotations.exactness(&unknown.id, Exactness::Derived);
838        if !unknown.links.is_empty() {
839            annotations.derived(&unknown.id, "links");
840        }
841        unknowns.push(unknown);
842    }
843
844    if counts.points == 0 && counts.surfaces() == 0 && counts.curves() == 0 {
845        return None;
846    }
847
848    let rmfastload_ids = scan
849        .container
850        .rmfastload_object_id_table()
851        .map(|(_, table)| {
852            table
853                .object_ids
854                .into_iter()
855                .map(|object_id| object_id.value)
856                .collect::<Vec<_>>()
857        })
858        .unwrap_or_default();
859    // Extract the native model once, before body selection: terminal-feature
860    // body selection and annotation attachment both read it, and extraction is
861    // pure, so building it here avoids re-parsing the same container/stream
862    // bytes for the seven feature/segment families body selection consumes.
863    // This moves extraction slightly earlier on the geometry path — the RFC's
864    // accepted memory-high-water cost.
865    let model = crate::native::NativeModel::extract(&scan.container, &scan.streams, &parsed);
866    let mut active_body_selection = select_active_body(&mut ir, &body_node_ids, &rmfastload_ids);
867    if !active_body_selection {
868        active_body_selection = select_terminal_feature_bodies(&mut ir, &model);
869    }
870    classify_body_kinds(&mut ir);
871    crate::native::attach_annotations(&mut ir, &model, scan, &mut annotations, &mut unknowns)
872        .ok()?;
873    prune_unreferenced_unknown_carriers(&mut ir);
874    finalize_point_topology(&mut ir, &mut annotations);
875    let referenced_pcurves: BTreeSet<_> = ir
876        .model
877        .coedges
878        .iter()
879        .flat_map(|coedge| coedge.pcurves.iter().map(|pcurve| pcurve.pcurve.clone()))
880        .collect();
881    ir.model
882        .pcurves
883        .retain(|pcurve| referenced_pcurves.contains(&pcurve.id));
884    retain_live_unknown_links(&ir, &mut unknowns, &mut annotations);
885    let mut annotations = annotations.build();
886    retain_live_annotations(&ir, &unknowns, &mut annotations);
887    let mut report = build_geometry_report(
888        scan,
889        &ir,
890        &counts,
891        !ir.model.faces.is_empty(),
892        ir.model.bodies.len() > 1 && !active_body_selection,
893        ir.model.tessellations.len(),
894    );
895    report_untransferred_streams(scan, &mut report);
896    Some((ir, report, annotations, unknowns))
897}
898
899pub(crate) fn prune_unreferenced_unknown_carriers(ir: &mut CadIr) {
900    let mut used_surfaces: BTreeSet<_> = ir
901        .model
902        .faces
903        .iter()
904        .map(|face| face.surface.clone())
905        .collect();
906    let mut used_curves: BTreeSet<_> = ir
907        .model
908        .edges
909        .iter()
910        .filter_map(|edge| edge.curve.clone())
911        .collect();
912    loop {
913        let previous = (used_surfaces.len(), used_curves.len());
914        for procedural in &ir.model.procedural_surfaces {
915            if !used_surfaces.contains(&procedural.surface) {
916                continue;
917            }
918            match &procedural.definition {
919                ProceduralSurfaceDefinition::Offset { support, .. } => {
920                    used_surfaces.insert(support.clone());
921                }
922                ProceduralSurfaceDefinition::Blend {
923                    supports, spine, ..
924                } => {
925                    used_surfaces.extend(
926                        supports
927                            .iter()
928                            .flatten()
929                            .map(|support| support.surface.clone()),
930                    );
931                    used_curves.extend(spine.iter().cloned());
932                }
933                _ => {}
934            }
935        }
936        for procedural in &ir.model.procedural_curves {
937            if !used_curves.contains(&procedural.curve) {
938                continue;
939            }
940            match &procedural.definition {
941                ProceduralCurveDefinition::Intersection { context, .. }
942                | ProceduralCurveDefinition::SurfaceCurve { context, .. } => {
943                    used_surfaces
944                        .extend(context.sides.iter().filter_map(|side| side.surface.clone()));
945                }
946                _ => {}
947            }
948        }
949        if previous == (used_surfaces.len(), used_curves.len()) {
950            break;
951        }
952    }
953    ir.model.surfaces.retain(|surface| {
954        !matches!(surface.geometry, SurfaceGeometry::Unknown { .. })
955            || used_surfaces.contains(&surface.id)
956    });
957    ir.model.curves.retain(|curve| {
958        !matches!(curve.geometry, CurveGeometry::Unknown { .. }) || used_curves.contains(&curve.id)
959    });
960}
961
962fn unmatched_delta_tombstone_count(scan: &Scan) -> usize {
963    let pairs = crate::native::paired_delta_streams(scan);
964    let mut current = pairs
965        .keys()
966        .map(|partition| (*partition, scan.streams[*partition].inflated.clone()))
967        .collect::<BTreeMap<_, _>>();
968    let paired_deltas = pairs.values().flatten().copied().collect::<BTreeSet<_>>();
969    let mut unmatched = 0usize;
970    for (delta, stream) in scan.streams.iter().enumerate() {
971        if stream.kind == StreamKind::Deltas && !paired_deltas.contains(&delta) {
972            unmatched += crate::deltas::unmatched_terminal_tombstones(&[], &stream.inflated);
973        }
974    }
975    for (partition, deltas) in pairs {
976        for delta in deltas {
977            let delta_bytes = &scan.streams[delta].inflated;
978            let partition_bytes = current
979                .get_mut(&partition)
980                .expect("paired partition was initialized");
981            unmatched += crate::deltas::unmatched_terminal_tombstones(partition_bytes, delta_bytes);
982            *partition_bytes = crate::deltas::merge_full_records(partition_bytes, delta_bytes);
983        }
984    }
985    unmatched
986}
987
988fn retain_live_annotations(
989    ir: &CadIr,
990    unknowns: &[UnknownRecord],
991    annotations: &mut cadmpeg_ir::Annotations,
992) {
993    let mut ids = BTreeSet::new();
994    macro_rules! add_ids {
995        ($($arena:expr),+ $(,)?) => {
996            $(ids.extend($arena.iter().map(|entity| entity.id.to_string()));)+
997        };
998    }
999    add_ids!(
1000        ir.model.bodies,
1001        ir.model.regions,
1002        ir.model.shells,
1003        ir.model.faces,
1004        ir.model.loops,
1005        ir.model.coedges,
1006        ir.model.edges,
1007        ir.model.vertices,
1008        ir.model.points,
1009        ir.model.surfaces,
1010        ir.model.curves,
1011        ir.model.pcurves,
1012        ir.model.procedural_surfaces,
1013        ir.model.procedural_curves,
1014        ir.model.features,
1015    );
1016    ids.extend(unknowns.iter().map(|unknown| unknown.id.to_string()));
1017    annotations.provenance.retain(|id, _| ids.contains(id));
1018    annotations.exactness.retain(|id, _| ids.contains(id));
1019}
1020
1021fn retain_live_unknown_links(
1022    ir: &CadIr,
1023    unknowns: &mut [UnknownRecord],
1024    annotations: &mut AnnotationBuilder,
1025) {
1026    let mut ids = BTreeSet::new();
1027    ids.extend(ir.model.surfaces.iter().map(|entity| entity.id.to_string()));
1028    ids.extend(ir.model.curves.iter().map(|entity| entity.id.to_string()));
1029    ids.extend(ir.model.pcurves.iter().map(|entity| entity.id.to_string()));
1030    ids.extend(
1031        ir.model
1032            .procedural_surfaces
1033            .iter()
1034            .map(|entity| entity.id.to_string()),
1035    );
1036    ids.extend(
1037        ir.model
1038            .procedural_curves
1039            .iter()
1040            .map(|entity| entity.id.to_string()),
1041    );
1042    let mut empty_links = Vec::new();
1043    for unknown in unknowns.iter_mut() {
1044        unknown.links.retain(|link| ids.contains(link));
1045        if unknown.links.is_empty() {
1046            empty_links.push(unknown.id.to_string());
1047        }
1048    }
1049    let _ = (empty_links, annotations);
1050}
1051
1052fn topology_body_node_ids(stream_index: usize, graph: &Graph) -> BTreeMap<BodyId, BTreeSet<u32>> {
1053    let prefix = format!("nx:s{stream_index}");
1054    let body_xmts: BTreeSet<_> = graph
1055        .body_shape_shells()
1056        .into_iter()
1057        .filter_map(|shell| shell.shell_fields().map(|fields| fields.body))
1058        .collect();
1059    body_xmts
1060        .into_iter()
1061        .map(|body_xmt| {
1062            let shells: BTreeSet<_> = graph
1063                .of_kind(13)
1064                .filter(|shell| {
1065                    shell
1066                        .shell_fields()
1067                        .is_some_and(|fields| fields.body == body_xmt)
1068                })
1069                .map(|shell| shell.xmt)
1070                .collect();
1071            let faces: Vec<_> = graph
1072                .of_kind(14)
1073                .filter(|face| {
1074                    face.face_fields()
1075                        .is_some_and(|fields| shells.contains(&fields.shell))
1076                })
1077                .collect();
1078            let face_xmts: BTreeSet<_> = faces.iter().map(|face| face.xmt).collect();
1079            let loops: BTreeSet<_> = graph
1080                .of_kind(15)
1081                .filter(|loop_| {
1082                    loop_
1083                        .loop_fields()
1084                        .is_some_and(|fields| face_xmts.contains(&fields.face))
1085                })
1086                .map(|loop_| loop_.xmt)
1087                .collect();
1088            let fins: Vec<_> = graph
1089                .of_kind(17)
1090                .filter(|fin| {
1091                    fin.fin_fields()
1092                        .is_some_and(|fields| loops.contains(&fields.loop_xmt))
1093                })
1094                .collect();
1095            let edge_xmts: BTreeSet<_> = fins
1096                .iter()
1097                .filter_map(|fin| fin.fin_fields().map(|fields| fields.edge))
1098                .collect();
1099            let vertex_xmts: BTreeSet<_> = fins
1100                .iter()
1101                .filter_map(|fin| fin.fin_fields().map(|fields| fields.vertex))
1102                .collect();
1103            let ids = faces
1104                .into_iter()
1105                .filter_map(|face| face.u32_at(4))
1106                .chain(
1107                    graph
1108                        .of_kind(16)
1109                        .filter(|edge| edge_xmts.contains(&edge.xmt))
1110                        .filter_map(|edge| edge.u32_at(4)),
1111                )
1112                .chain(
1113                    graph
1114                        .of_kind(18)
1115                        .filter(|vertex| vertex_xmts.contains(&vertex.xmt))
1116                        .filter_map(|vertex| vertex.u32_at(4)),
1117                )
1118                .collect();
1119            (BodyId(format!("{prefix}:body#{body_xmt}")), ids)
1120        })
1121        .collect()
1122}
1123
1124fn select_active_body(
1125    ir: &mut CadIr,
1126    body_node_ids: &BTreeMap<BodyId, BTreeSet<u32>>,
1127    rmfastload_ids: &[u32],
1128) -> bool {
1129    if rmfastload_ids.is_empty() || ir.model.bodies.len() <= 1 {
1130        return false;
1131    }
1132    let active: BTreeSet<_> = rmfastload_ids.iter().copied().collect();
1133    let mut scored: Vec<_> = ir
1134        .model
1135        .bodies
1136        .iter()
1137        .map(|body| {
1138            let ids = body_node_ids.get(&body.id);
1139            let count = ids.map_or(0, BTreeSet::len);
1140            let hits = ids.map_or(0, |ids| ids.intersection(&active).count());
1141            (hits, count, body.id.clone())
1142        })
1143        .collect();
1144    scored.sort_by(|first, second| second.0.cmp(&first.0).then(second.1.cmp(&first.1)));
1145    let Some(&(top_hits, top_count, ref top_body)) = scored.first() else {
1146        return false;
1147    };
1148    let next_hits = scored.get(1).map_or(0, |score| score.0);
1149    let mut selected: BTreeSet<_> = scored
1150        .iter()
1151        .filter(|(hits, count, _)| *hits > 0 && *count > 0 && (*hits as f64 / *count as f64) > 0.10)
1152        .map(|(_, _, body)| body.clone())
1153        .collect();
1154    let dominant = top_hits >= 5 * next_hits.max(1);
1155    if dominant {
1156        selected.retain(|body| body == top_body);
1157    }
1158    if top_count == 0
1159        || (top_hits as f64 / top_count as f64) <= 0.10
1160        || selected.is_empty()
1161        || (selected.len() == 1 && !dominant)
1162    {
1163        return false;
1164    }
1165    prune_inactive_topology(ir, &selected);
1166    if let Some(source) = &mut ir.source {
1167        source.attributes.insert(
1168            "active_body_selector".to_string(),
1169            "rmfastload_object_id_membership".to_string(),
1170        );
1171        source
1172            .attributes
1173            .insert("rmfastload_hits".to_string(), top_hits.to_string());
1174        source.attributes.insert(
1175            "rmfastload_active_body_count".to_string(),
1176            selected.len().to_string(),
1177        );
1178    }
1179    true
1180}
1181
1182fn select_terminal_feature_bodies(ir: &mut CadIr, model: &crate::native::NativeModel) -> bool {
1183    if ir.model.bodies.len() <= 1 {
1184        return false;
1185    }
1186    // These families are read straight from the pre-built model; extracting
1187    // them here as well would parse the same container bytes a second time.
1188    // `feature_operation_body_operands` already folds in the body-member and
1189    // reference-occurrence families the legacy code computed inline.
1190    let labels = model.features.feature_operation_labels.as_slice();
1191    let body_references = model.features.feature_body_references.as_slice();
1192    let booleans = model.features.feature_boolean_operations.as_slice();
1193    let bindings = model.segments.segment_body_bindings.as_slice();
1194    let body_operands = model.features.feature_operation_body_operands.as_slice();
1195    if booleans.is_empty() && body_operands.is_empty() {
1196        return false;
1197    }
1198    let Some(statuses) = crate::native::segment_body_lineage_statuses(
1199        labels,
1200        body_references,
1201        booleans,
1202        body_operands,
1203        bindings,
1204    ) else {
1205        return false;
1206    };
1207    let mut mapped = BTreeSet::new();
1208    let mut selected = BTreeSet::new();
1209    for (binding, status) in bindings.iter().filter_map(|binding| {
1210        statuses
1211            .iter()
1212            .find(|status| status.segment_body_binding == binding.id)
1213            .map(|status| (binding, status))
1214    }) {
1215        let prefix = format!("nx:s{}:", binding.stream_ordinal);
1216        let stream_bodies = ir
1217            .model
1218            .bodies
1219            .iter()
1220            .filter(|body| body.id.0.starts_with(&prefix))
1221            .map(|body| body.id.clone())
1222            .collect::<Vec<_>>();
1223        if stream_bodies.is_empty() {
1224            continue;
1225        }
1226        mapped.extend(stream_bodies.iter().cloned());
1227        if status.terminal {
1228            selected.extend(stream_bodies);
1229        }
1230    }
1231    let emitted = ir
1232        .model
1233        .bodies
1234        .iter()
1235        .map(|body| body.id.clone())
1236        .collect::<BTreeSet<_>>();
1237    if mapped != emitted || selected.is_empty() || selected.len() == emitted.len() {
1238        return false;
1239    }
1240
1241    prune_inactive_topology(ir, &selected);
1242    if let Some(source) = &mut ir.source {
1243        source.attributes.insert(
1244            "active_body_selector".to_string(),
1245            "terminal_feature_body_lineage".to_string(),
1246        );
1247        source.attributes.insert(
1248            "feature_terminal_body_count".to_string(),
1249            selected.len().to_string(),
1250        );
1251    }
1252    true
1253}
1254
1255fn prune_inactive_topology(ir: &mut CadIr, selected: &BTreeSet<BodyId>) {
1256    ir.model.bodies.retain(|body| selected.contains(&body.id));
1257    ir.model
1258        .regions
1259        .retain(|region| selected.contains(&region.body));
1260    let regions: BTreeSet<_> = ir
1261        .model
1262        .regions
1263        .iter()
1264        .map(|region| region.id.clone())
1265        .collect();
1266    ir.model
1267        .shells
1268        .retain(|shell| regions.contains(&shell.region));
1269    let shells: BTreeSet<_> = ir
1270        .model
1271        .shells
1272        .iter()
1273        .map(|shell| shell.id.clone())
1274        .collect();
1275    ir.model.faces.retain(|face| shells.contains(&face.shell));
1276    let faces: BTreeSet<_> = ir.model.faces.iter().map(|face| face.id.clone()).collect();
1277    ir.model.loops.retain(|loop_| faces.contains(&loop_.face));
1278    let loops: BTreeSet<_> = ir
1279        .model
1280        .loops
1281        .iter()
1282        .map(|loop_| loop_.id.clone())
1283        .collect();
1284    ir.model
1285        .coedges
1286        .retain(|coedge| loops.contains(&coedge.owner_loop));
1287    let edges: BTreeSet<_> = ir
1288        .model
1289        .coedges
1290        .iter()
1291        .map(|coedge| coedge.edge.clone())
1292        .chain(
1293            ir.model
1294                .shells
1295                .iter()
1296                .flat_map(|shell| shell.wire_edges.iter().cloned()),
1297        )
1298        .collect();
1299    ir.model.edges.retain(|edge| edges.contains(&edge.id));
1300    let vertices: BTreeSet<_> = ir
1301        .model
1302        .edges
1303        .iter()
1304        .flat_map(|edge| [edge.start.clone(), edge.end.clone()])
1305        .chain(
1306            ir.model
1307                .shells
1308                .iter()
1309                .flat_map(|shell| shell.free_vertices.iter().cloned()),
1310        )
1311        .collect();
1312    ir.model
1313        .vertices
1314        .retain(|vertex| vertices.contains(&vertex.id));
1315    let points: BTreeSet<_> = ir
1316        .model
1317        .vertices
1318        .iter()
1319        .map(|vertex| vertex.point.clone())
1320        .collect();
1321    ir.model.points.retain(|point| points.contains(&point.id));
1322    prune_inactive_geometry(ir);
1323}
1324
1325fn prune_inactive_geometry(ir: &mut CadIr) {
1326    let mut surfaces: BTreeSet<_> = ir
1327        .model
1328        .faces
1329        .iter()
1330        .map(|face| face.surface.clone())
1331        .collect();
1332    let mut curves: BTreeSet<_> = ir
1333        .model
1334        .edges
1335        .iter()
1336        .filter_map(|edge| edge.curve.clone())
1337        .collect();
1338    let pcurves: BTreeSet<_> = ir
1339        .model
1340        .coedges
1341        .iter()
1342        .flat_map(|coedge| coedge.pcurves.iter().map(|pcurve| pcurve.pcurve.clone()))
1343        .collect();
1344
1345    loop {
1346        let old_surface_count = surfaces.len();
1347        let old_curve_count = curves.len();
1348        for procedural in &ir.model.procedural_surfaces {
1349            if !surfaces.contains(&procedural.surface) {
1350                continue;
1351            }
1352            match &procedural.definition {
1353                ProceduralSurfaceDefinition::Offset { support, .. } => {
1354                    surfaces.insert(support.clone());
1355                }
1356                ProceduralSurfaceDefinition::Blend {
1357                    supports, spine, ..
1358                } => {
1359                    surfaces.extend(
1360                        supports
1361                            .iter()
1362                            .flatten()
1363                            .map(|support| support.surface.clone()),
1364                    );
1365                    curves.extend(spine.iter().cloned());
1366                }
1367                _ => {}
1368            }
1369        }
1370        for procedural in &ir.model.procedural_curves {
1371            if !curves.contains(&procedural.curve) {
1372                continue;
1373            }
1374            match &procedural.definition {
1375                ProceduralCurveDefinition::Intersection { context, .. }
1376                | ProceduralCurveDefinition::SurfaceCurve { context, .. } => {
1377                    surfaces.extend(context.sides.iter().filter_map(|side| side.surface.clone()));
1378                }
1379                _ => {}
1380            }
1381        }
1382        if surfaces.len() == old_surface_count && curves.len() == old_curve_count {
1383            break;
1384        }
1385    }
1386
1387    ir.model
1388        .procedural_surfaces
1389        .retain(|procedural| surfaces.contains(&procedural.surface));
1390    ir.model
1391        .procedural_curves
1392        .retain(|procedural| curves.contains(&procedural.curve));
1393    ir.model
1394        .surfaces
1395        .retain(|surface| surfaces.contains(&surface.id));
1396    ir.model.curves.retain(|curve| curves.contains(&curve.id));
1397    ir.model
1398        .pcurves
1399        .retain(|pcurve| pcurves.contains(&pcurve.id));
1400}
1401
1402fn finalize_point_topology(ir: &mut CadIr, annotations: &mut AnnotationBuilder) {
1403    let referenced_points: BTreeSet<_> = ir
1404        .model
1405        .vertices
1406        .iter()
1407        .map(|vertex| vertex.point.clone())
1408        .collect();
1409    if !ir.model.bodies.is_empty() {
1410        ir.model
1411            .points
1412            .retain(|point| referenced_points.contains(&point.id));
1413        return;
1414    }
1415
1416    if ir.model.points.is_empty() {
1417        return;
1418    }
1419
1420    let body_id = BodyId("nx:derived:point-body#0".to_string());
1421    let region_id = RegionId("nx:derived:point-region#0".to_string());
1422    let shell_id = ShellId("nx:derived:point-shell#0".to_string());
1423    let stream = annotations.stream("nx:container");
1424    for id in [&body_id.0, &region_id.0, &shell_id.0] {
1425        annotations
1426            .note(id, stream, 0)
1427            .tag("derived_point_topology");
1428        annotations.exactness(id, Exactness::Inferred);
1429    }
1430
1431    let mut free_vertices = Vec::with_capacity(ir.model.points.len());
1432    for (index, point) in ir.model.points.iter().enumerate() {
1433        let vertex_id = VertexId(format!("nx:derived:point-vertex#{index}"));
1434        annotations
1435            .note(&vertex_id, stream, 0)
1436            .tag("derived_point_topology");
1437        annotations.exactness(&vertex_id, Exactness::Inferred);
1438        ir.model.vertices.push(Vertex {
1439            id: vertex_id.clone(),
1440            point: point.id.clone(),
1441            tolerance: None,
1442        });
1443        free_vertices.push(vertex_id);
1444    }
1445    ir.model.shells.push(Shell {
1446        id: shell_id.clone(),
1447        region: region_id.clone(),
1448        faces: Vec::new(),
1449        wire_edges: Vec::new(),
1450        free_vertices,
1451    });
1452    ir.model.regions.push(Region {
1453        id: region_id.clone(),
1454        body: body_id.clone(),
1455        shells: vec![shell_id],
1456    });
1457    ir.model.bodies.push(Body {
1458        id: body_id,
1459        kind: BodyKind::General,
1460        regions: vec![region_id],
1461        transform: None,
1462        name: None,
1463        color: None,
1464        visible: None,
1465    });
1466}
1467
1468fn classify_body_kinds(ir: &mut CadIr) {
1469    let region_bodies: BTreeMap<_, _> = ir
1470        .model
1471        .regions
1472        .iter()
1473        .map(|region| (region.id.clone(), region.body.clone()))
1474        .collect();
1475    let shell_bodies: BTreeMap<_, _> = ir
1476        .model
1477        .shells
1478        .iter()
1479        .filter_map(|shell| {
1480            region_bodies
1481                .get(&shell.region)
1482                .cloned()
1483                .map(|body| (shell.id.clone(), body))
1484        })
1485        .collect();
1486    let face_bodies: BTreeMap<_, _> = ir
1487        .model
1488        .faces
1489        .iter()
1490        .filter_map(|face| {
1491            shell_bodies
1492                .get(&face.shell)
1493                .cloned()
1494                .map(|body| (face.id.clone(), body))
1495        })
1496        .collect();
1497    let loop_bodies: BTreeMap<_, _> = ir
1498        .model
1499        .loops
1500        .iter()
1501        .filter_map(|loop_| {
1502            face_bodies
1503                .get(&loop_.face)
1504                .cloned()
1505                .map(|body| (loop_.id.clone(), body))
1506        })
1507        .collect();
1508    let coedge_bodies: BTreeMap<_, _> = ir
1509        .model
1510        .coedges
1511        .iter()
1512        .filter_map(|coedge| {
1513            loop_bodies
1514                .get(&coedge.owner_loop)
1515                .cloned()
1516                .map(|body| (coedge.id.clone(), body))
1517        })
1518        .collect();
1519    let mut edge_uses = BTreeMap::<BodyId, BTreeMap<EdgeId, usize>>::new();
1520    for coedge in &ir.model.coedges {
1521        let Some(body) = coedge_bodies.get(&coedge.id) else {
1522            continue;
1523        };
1524        *edge_uses
1525            .entry(body.clone())
1526            .or_default()
1527            .entry(coedge.edge.clone())
1528            .or_default() += 1;
1529    }
1530    for body in &mut ir.model.bodies {
1531        body.kind = if edge_uses
1532            .get(&body.id)
1533            .is_some_and(|uses| !uses.is_empty() && uses.values().all(|use_count| *use_count == 2))
1534        {
1535            BodyKind::Solid
1536        } else {
1537            BodyKind::Sheet
1538        };
1539    }
1540}
1541
1542fn linear_knots(parameters: &[f64]) -> Vec<f64> {
1543    let mut knots = Vec::with_capacity(parameters.len() + 2);
1544    knots.push(parameters[0]);
1545    knots.extend_from_slice(parameters);
1546    knots.push(*parameters.last().expect("non-empty chart parameters"));
1547    knots
1548}
1549
1550pub(crate) fn assign_ext11_support_uv(
1551    ir: &CadIr,
1552    surfaces_by_xmt: &BTreeMap<u32, SurfaceId>,
1553    supports: [u32; 2],
1554    points: &[Point3],
1555    fit_tolerance: f64,
1556    lanes: &[Option<Vec<[f64; 2]>>; 2],
1557) -> Option<[Option<Vec<[f64; 2]>>; 2]> {
1558    let surface_ids = supports.map(|support| surfaces_by_xmt.get(&support).cloned());
1559    let [Some(first_surface), Some(second_surface)] = surface_ids else {
1560        return None;
1561    };
1562    assign_ext11_support_uv_to_surfaces(
1563        ir,
1564        [&first_surface, &second_surface],
1565        points,
1566        fit_tolerance,
1567        lanes,
1568    )
1569}
1570
1571pub(crate) fn assign_ext11_support_uv_to_surfaces(
1572    ir: &CadIr,
1573    surfaces: [&SurfaceId; 2],
1574    points: &[Point3],
1575    fit_tolerance: f64,
1576    lanes: &[Option<Vec<[f64; 2]>>; 2],
1577) -> Option<[Option<Vec<[f64; 2]>>; 2]> {
1578    let lane_matches_surface = |surface: &SurfaceId, lane: usize| {
1579        let Some(values) = lanes[lane]
1580            .as_deref()
1581            .filter(|values| values.len() == points.len())
1582        else {
1583            return false;
1584        };
1585        let Some(geometry) = ir
1586            .model
1587            .surfaces
1588            .iter()
1589            .find(|candidate| &candidate.id == surface)
1590            .map(|surface| &surface.geometry)
1591        else {
1592            return false;
1593        };
1594        values.iter().zip(points).all(|(uv, point)| {
1595            let Some(uv) = surface_parameters(geometry, *uv) else {
1596                return false;
1597            };
1598            model_surface_point_by_id(ir, surface, uv.u, uv.v)
1599                .is_some_and(|candidate| point_distance(candidate, *point) <= fit_tolerance)
1600        })
1601    };
1602    let matches = [
1603        [
1604            lane_matches_surface(surfaces[0], 0),
1605            lane_matches_surface(surfaces[0], 1),
1606        ],
1607        [
1608            lane_matches_surface(surfaces[1], 0),
1609            lane_matches_surface(surfaces[1], 1),
1610        ],
1611    ];
1612    let mut assigned = [None, None];
1613    let mut assigned_lanes = [None, None];
1614    for lane in 0..2 {
1615        let support_matches = [matches[0][lane], matches[1][lane]];
1616        let Some(support) = support_matches
1617            .iter()
1618            .position(|matches| *matches)
1619            .filter(|_| support_matches.iter().filter(|matches| **matches).count() == 1)
1620        else {
1621            continue;
1622        };
1623        if assigned[support].is_some() {
1624            return None;
1625        }
1626        assigned[support].clone_from(&lanes[lane]);
1627        assigned_lanes[support] = Some(lane);
1628    }
1629    if surfaces[0] != surfaces[1] && assigned.iter().filter(|lane| lane.is_some()).count() == 1 {
1630        let assigned_support = assigned.iter().position(Option::is_some)?;
1631        let assigned_lane = assigned_lanes[assigned_support]?;
1632        let other_support = 1 - assigned_support;
1633        let other_lane = 1 - assigned_lane;
1634        if lane_matches_surface(surfaces[other_support], other_lane) {
1635            assigned[other_support].clone_from(&lanes[other_lane]);
1636        }
1637    }
1638    assigned.iter().any(Option::is_some).then_some(assigned)
1639}
1640
1641pub(crate) type PendingExt11SupportUv = (
1642    ProceduralCurveId,
1643    Vec<Point3>,
1644    Vec<f64>,
1645    f64,
1646    [Option<Vec<[f64; 2]>>; 2],
1647);
1648
1649fn missing_support_parameter(value: f64) -> bool {
1650    value.to_bits() == MISSING_TOLERANCE.to_bits()
1651}
1652
1653fn pcurve_requires_completion(pcurve: Option<&PcurveGeometry>) -> bool {
1654    match pcurve {
1655        None => true,
1656        Some(PcurveGeometry::Nurbs { control_points, .. }) => control_points.iter().any(|point| {
1657            !point.u.is_finite()
1658                || !point.v.is_finite()
1659                || missing_support_parameter(point.u)
1660                || missing_support_parameter(point.v)
1661        }),
1662        Some(PcurveGeometry::Line { origin, direction }) => [origin, direction]
1663            .into_iter()
1664            .any(|point| !point.u.is_finite() || !point.v.is_finite()),
1665        Some(_) => false,
1666    }
1667}
1668
1669fn pcurve_control_point_seed(pcurve: Option<&PcurveGeometry>, index: usize) -> Option<Point2> {
1670    let PcurveGeometry::Nurbs { control_points, .. } = pcurve? else {
1671        return None;
1672    };
1673    control_points.get(index).copied().filter(|point| {
1674        point.u.is_finite()
1675            && point.v.is_finite()
1676            && !missing_support_parameter(point.u)
1677            && !missing_support_parameter(point.v)
1678    })
1679}
1680
1681pub(crate) fn complete_ext11_support_uv(ir: &mut CadIr, pending: &[PendingExt11SupportUv]) {
1682    for (procedural_id, points, parameters, fit_tolerance, lanes) in pending {
1683        let Some(procedural_index) = ir
1684            .model
1685            .procedural_curves
1686            .iter()
1687            .position(|procedural| &procedural.id == procedural_id)
1688        else {
1689            continue;
1690        };
1691        let (surfaces, missing) = match &ir.model.procedural_curves[procedural_index].definition {
1692            ProceduralCurveDefinition::Intersection { context, .. } => {
1693                let [Some(first), Some(second)] = &context.sides.clone().map(|side| side.surface)
1694                else {
1695                    continue;
1696                };
1697                (
1698                    [first.clone(), second.clone()],
1699                    context
1700                        .sides
1701                        .each_ref()
1702                        .map(|side| pcurve_requires_completion(side.pcurve.as_ref())),
1703                )
1704            }
1705            _ => continue,
1706        };
1707        if !missing.into_iter().any(|missing| missing) {
1708            continue;
1709        }
1710        let Some(assigned) = assign_ext11_support_uv_to_surfaces(
1711            ir,
1712            [&surfaces[0], &surfaces[1]],
1713            points,
1714            *fit_tolerance,
1715            lanes,
1716        ) else {
1717            continue;
1718        };
1719        let replacements: [Option<PcurveGeometry>; 2] = std::array::from_fn(|side| {
1720            if !missing[side] {
1721                return None;
1722            }
1723            let surface_geometry = ir
1724                .model
1725                .surfaces
1726                .iter()
1727                .find(|surface| surface.id == surfaces[side])
1728                .map(|surface| &surface.geometry)?;
1729            let values = assigned[side].as_ref()?;
1730            if values
1731                .iter()
1732                .flatten()
1733                .any(|value| !value.is_finite() || missing_support_parameter(*value))
1734            {
1735                return None;
1736            }
1737            let control_points = values
1738                .iter()
1739                .map(|uv| surface_parameters(surface_geometry, *uv))
1740                .collect::<Option<Vec<_>>>()?;
1741            Some(PcurveGeometry::Nurbs {
1742                degree: 1,
1743                knots: linear_knots(parameters),
1744                control_points,
1745                weights: None,
1746                periodic: false,
1747            })
1748        });
1749        let ProceduralCurveDefinition::Intersection { context, .. } =
1750            &mut ir.model.procedural_curves[procedural_index].definition
1751        else {
1752            unreachable!("definition checked above");
1753        };
1754        for (side, replacement) in replacements.into_iter().enumerate() {
1755            if let Some(replacement) = replacement {
1756                context.sides[side].pcurve = Some(replacement);
1757            }
1758        }
1759    }
1760}
1761
1762pub(crate) fn complete_support_uv(ir: &mut CadIr, pending: &[PendingExt11SupportUv]) {
1763    loop {
1764        let before = pending_support_lanes_requiring_completion(ir, pending);
1765        complete_support_uv_wave(ir, pending);
1766        let after = pending_support_lanes_requiring_completion(ir, pending);
1767        if after >= before {
1768            break;
1769        }
1770    }
1771}
1772
1773fn pending_support_lanes_requiring_completion(
1774    ir: &CadIr,
1775    pending: &[PendingExt11SupportUv],
1776) -> usize {
1777    pending
1778        .iter()
1779        .filter_map(|(procedural_id, ..)| {
1780            ir.model
1781                .procedural_curves
1782                .iter()
1783                .find(|procedural| &procedural.id == procedural_id)
1784        })
1785        .filter_map(|procedural| {
1786            let ProceduralCurveDefinition::Intersection { context, .. } = &procedural.definition
1787            else {
1788                return None;
1789            };
1790            Some(
1791                context
1792                    .sides
1793                    .iter()
1794                    .filter(|side| pcurve_requires_completion(side.pcurve.as_ref()))
1795                    .count(),
1796            )
1797        })
1798        .sum()
1799}
1800
1801fn complete_support_uv_wave(ir: &mut CadIr, pending: &[PendingExt11SupportUv]) {
1802    let mut replacements = Vec::new();
1803    let mut blend_parameter_grids = BTreeMap::<SurfaceId, Option<Vec<(Point2, Point3)>>>::new();
1804    for (procedural_id, points, parameters, fit_tolerance, _) in pending {
1805        let Some(procedural) = ir
1806            .model
1807            .procedural_curves
1808            .iter()
1809            .find(|procedural| &procedural.id == procedural_id)
1810        else {
1811            continue;
1812        };
1813        let ProceduralCurveDefinition::Intersection { context, .. } = &procedural.definition else {
1814            continue;
1815        };
1816        for side in 0..2 {
1817            if !pcurve_requires_completion(context.sides[side].pcurve.as_ref()) {
1818                continue;
1819            }
1820            let Some(surface_id) = &context.sides[side].surface else {
1821                continue;
1822            };
1823            let Some(surface) = ir
1824                .model
1825                .surfaces
1826                .iter()
1827                .find(|surface| &surface.id == surface_id)
1828            else {
1829                continue;
1830            };
1831            let effective_fit_tolerance =
1832                blend_spine_cache_fit_tolerance(ir, surface_id, *fit_tolerance);
1833            let mut uv = Vec::with_capacity(points.len());
1834            for (point_index, point) in points.iter().enumerate() {
1835                let seed =
1836                    pcurve_control_point_seed(context.sides[side].pcurve.as_ref(), point_index)
1837                        .or_else(|| uv.last().copied());
1838                let parameters = match &surface.geometry {
1839                    SurfaceGeometry::Nurbs(nurbs) => nurbs_parameters(nurbs, *point, seed),
1840                    SurfaceGeometry::Procedural { .. } => {
1841                        let other_side = &context.sides[1 - side];
1842                        other_side
1843                            .surface
1844                            .as_ref()
1845                            .zip(other_side.pcurve.as_ref())
1846                            .and_then(|(other_surface, other_pcurve)| {
1847                                blend_boundary_parameter_from_support_pcurve(
1848                                    ir,
1849                                    surface_id,
1850                                    other_surface,
1851                                    other_pcurve,
1852                                    parameters[point_index],
1853                                    *point,
1854                                    effective_fit_tolerance,
1855                                )
1856                            })
1857                            .or_else(|| {
1858                                offset_surface_parameters_with_tolerance(
1859                                    ir,
1860                                    surface_id,
1861                                    *point,
1862                                    seed,
1863                                    Some(effective_fit_tolerance),
1864                                )
1865                            })
1866                            .or_else(|| {
1867                                blend_surface_parameters_for_fit_with_grid(
1868                                    ir,
1869                                    surface_id,
1870                                    *point,
1871                                    seed,
1872                                    effective_fit_tolerance,
1873                                    BlendParameterGrid::Disabled,
1874                                )
1875                            })
1876                            .or_else(|| {
1877                                let blend_grid = blend_parameter_grids
1878                                    .entry(surface_id.clone())
1879                                    .or_insert_with(|| {
1880                                        blend_surface_parameter_grid(ir, surface_id, 0)
1881                                    });
1882                                blend_surface_parameters_from_grid_for_fit(
1883                                    ir,
1884                                    surface_id,
1885                                    *point,
1886                                    effective_fit_tolerance,
1887                                    blend_grid.as_deref()?,
1888                                )
1889                            })
1890                    }
1891                    geometry => analytic_surface_parameters(geometry, *point),
1892                };
1893                let Some(parameters) = parameters else {
1894                    uv.clear();
1895                    break;
1896                };
1897                uv.push(parameters);
1898            }
1899            if uv.len() != points.len() {
1900                continue;
1901            }
1902            if matches!(
1903                surface.geometry,
1904                SurfaceGeometry::Cylinder { .. }
1905                    | SurfaceGeometry::Cone { .. }
1906                    | SurfaceGeometry::Sphere { .. }
1907                    | SurfaceGeometry::Torus { .. }
1908            ) {
1909                for index in 1..uv.len() {
1910                    let turns = ((uv[index - 1].u - uv[index].u) / std::f64::consts::TAU).round();
1911                    uv[index].u += turns * std::f64::consts::TAU;
1912                }
1913            }
1914            let reproduces_chart = uv.iter().zip(points).all(|(uv, point)| {
1915                decoded_surface_point(ir, surface_id, uv.u, uv.v)
1916                    .is_some_and(|actual| point_distance(actual, *point) <= effective_fit_tolerance)
1917            });
1918            if reproduces_chart {
1919                replacements.push((
1920                    procedural_id.clone(),
1921                    side,
1922                    PcurveGeometry::Nurbs {
1923                        degree: 1,
1924                        knots: linear_knots(parameters),
1925                        control_points: uv,
1926                        weights: None,
1927                        periodic: false,
1928                    },
1929                    effective_fit_tolerance,
1930                ));
1931            }
1932        }
1933    }
1934    for (procedural_id, side, pcurve, effective_fit_tolerance) in replacements {
1935        let Some(procedural) = ir
1936            .model
1937            .procedural_curves
1938            .iter_mut()
1939            .find(|procedural| procedural.id == procedural_id)
1940        else {
1941            continue;
1942        };
1943        let ProceduralCurveDefinition::Intersection { context, .. } = &mut procedural.definition
1944        else {
1945            continue;
1946        };
1947        if pcurve_requires_completion(context.sides[side].pcurve.as_ref()) {
1948            context.sides[side].pcurve = Some(pcurve);
1949            procedural.cache_fit_tolerance = Some(
1950                procedural
1951                    .cache_fit_tolerance
1952                    .unwrap_or(0.0)
1953                    .max(effective_fit_tolerance),
1954            );
1955        }
1956    }
1957    complete_coupled_support_uv(ir, pending);
1958}
1959
1960pub(crate) fn blend_spine_cache_fit_tolerance(
1961    ir: &CadIr,
1962    surface: &SurfaceId,
1963    fit_tolerance: f64,
1964) -> f64 {
1965    blend_surface_definition(ir, surface)
1966        .and_then(|(_, spine, _, _)| {
1967            ir.model
1968                .procedural_curves
1969                .iter()
1970                .find(|procedural| procedural.curve == spine)
1971                .and_then(|procedural| procedural.cache_fit_tolerance)
1972        })
1973        .filter(|tolerance| tolerance.is_finite() && *tolerance > 0.0)
1974        .map_or(fit_tolerance, |tolerance| fit_tolerance + tolerance)
1975}
1976
1977fn complete_coupled_support_uv(ir: &mut CadIr, pending: &[PendingExt11SupportUv]) {
1978    let mut replacements = Vec::new();
1979    for (procedural_id, points, parameters, fit_tolerance, _) in pending {
1980        let Some(procedural) = ir
1981            .model
1982            .procedural_curves
1983            .iter()
1984            .find(|procedural| &procedural.id == procedural_id)
1985        else {
1986            continue;
1987        };
1988        let ProceduralCurveDefinition::Intersection { context, .. } = &procedural.definition else {
1989            continue;
1990        };
1991        let missing = context
1992            .sides
1993            .each_ref()
1994            .map(|side| pcurve_requires_completion(side.pcurve.as_ref()));
1995        let [Some(first_surface), Some(second_surface)] =
1996            context.sides.each_ref().map(|side| side.surface.as_ref())
1997        else {
1998            continue;
1999        };
2000        let surfaces = [first_surface, second_surface];
2001        let unresolved_procedural_support = (0..2).any(|side| {
2002            missing[side]
2003                && pcurve_control_point_seed(context.sides[side].pcurve.as_ref(), 0).is_some()
2004                && ir.model.surfaces.iter().any(|surface| {
2005                    &surface.id == surfaces[side]
2006                        && matches!(surface.geometry, SurfaceGeometry::Procedural { .. })
2007                })
2008        });
2009        if !unresolved_procedural_support {
2010            continue;
2011        }
2012        let seeds = context
2013            .sides
2014            .each_ref()
2015            .map(|side| pcurve_control_point_seed(side.pcurve.as_ref(), 0));
2016        let Some(lanes) = continue_surface_intersection_parameters_with_seeds(
2017            ir,
2018            surfaces,
2019            points,
2020            *fit_tolerance,
2021            seeds,
2022        ) else {
2023            continue;
2024        };
2025        for side in 0..2 {
2026            if missing[side] {
2027                replacements.push((
2028                    procedural_id.clone(),
2029                    side,
2030                    PcurveGeometry::Nurbs {
2031                        degree: 1,
2032                        knots: linear_knots(parameters),
2033                        control_points: lanes[side].clone(),
2034                        weights: None,
2035                        periodic: false,
2036                    },
2037                ));
2038            }
2039        }
2040    }
2041    for (procedural_id, side, pcurve) in replacements {
2042        let Some(procedural) = ir
2043            .model
2044            .procedural_curves
2045            .iter_mut()
2046            .find(|procedural| procedural.id == procedural_id)
2047        else {
2048            continue;
2049        };
2050        let ProceduralCurveDefinition::Intersection { context, .. } = &mut procedural.definition
2051        else {
2052            continue;
2053        };
2054        if pcurve_requires_completion(context.sides[side].pcurve.as_ref()) {
2055            context.sides[side].pcurve = Some(pcurve);
2056        }
2057    }
2058}
2059
2060pub(crate) fn complete_parameterization_equivalent_support_uv(ir: &mut CadIr) {
2061    let replacements = ir
2062        .model
2063        .procedural_curves
2064        .iter()
2065        .enumerate()
2066        .filter_map(|(procedural_index, procedural)| {
2067            let ProceduralCurveDefinition::Intersection { context, .. } = &procedural.definition
2068            else {
2069                return None;
2070            };
2071            let missing = context
2072                .sides
2073                .each_ref()
2074                .map(|side| pcurve_requires_completion(side.pcurve.as_ref()));
2075            let target = match missing {
2076                [true, false] => 0,
2077                [false, true] => 1,
2078                _ => return None,
2079            };
2080            let source = 1 - target;
2081            let (Some(target_surface), Some(source_surface), Some(source_pcurve)) = (
2082                context.sides[target].surface.as_ref(),
2083                context.sides[source].surface.as_ref(),
2084                context.sides[source].pcurve.as_ref(),
2085            ) else {
2086                return None;
2087            };
2088            parameterization_equivalent_surfaces(ir, target_surface, source_surface)
2089                .then(|| (procedural_index, target, source_pcurve.clone()))
2090        })
2091        .collect::<Vec<_>>();
2092    for (procedural_index, side, pcurve) in replacements {
2093        let ProceduralCurveDefinition::Intersection { context, .. } =
2094            &mut ir.model.procedural_curves[procedural_index].definition
2095        else {
2096            unreachable!("definition selected above");
2097        };
2098        if pcurve_requires_completion(context.sides[side].pcurve.as_ref()) {
2099            context.sides[side].pcurve = Some(pcurve);
2100        }
2101    }
2102}
2103
2104pub(crate) fn parameterization_equivalent_surfaces(
2105    ir: &CadIr,
2106    first: &SurfaceId,
2107    second: &SurfaceId,
2108) -> bool {
2109    fn equivalent(
2110        ir: &CadIr,
2111        first: &SurfaceId,
2112        second: &SurfaceId,
2113        visited: &mut BTreeSet<(SurfaceId, SurfaceId)>,
2114    ) -> bool {
2115        if first == second {
2116            return true;
2117        }
2118        if !visited.insert((first.clone(), second.clone())) {
2119            return false;
2120        }
2121        let geometry = |id: &SurfaceId| {
2122            ir.model
2123                .surfaces
2124                .iter()
2125                .find(|surface| &surface.id == id)
2126                .map(|surface| &surface.geometry)
2127        };
2128        let (Some(first_geometry), Some(second_geometry)) = (geometry(first), geometry(second))
2129        else {
2130            return false;
2131        };
2132        if first_geometry == second_geometry {
2133            return true;
2134        }
2135        let construction = |geometry: &SurfaceGeometry| {
2136            let SurfaceGeometry::Procedural { construction } = geometry else {
2137                return None;
2138            };
2139            ir.model
2140                .procedural_surfaces
2141                .iter()
2142                .find(|procedural| &procedural.id == construction)
2143                .map(|procedural| &procedural.definition)
2144        };
2145        let (
2146            Some(ProceduralSurfaceDefinition::Offset {
2147                support: first_support,
2148                distance: first_distance,
2149                u_sense: first_u_sense,
2150                v_sense: first_v_sense,
2151                extension_flags: first_extensions,
2152                ..
2153            }),
2154            Some(ProceduralSurfaceDefinition::Offset {
2155                support: second_support,
2156                distance: second_distance,
2157                u_sense: second_u_sense,
2158                v_sense: second_v_sense,
2159                extension_flags: second_extensions,
2160                ..
2161            }),
2162        ) = (construction(first_geometry), construction(second_geometry))
2163        else {
2164            return false;
2165        };
2166        first_distance.to_bits() == second_distance.to_bits()
2167            && first_u_sense == second_u_sense
2168            && first_v_sense == second_v_sense
2169            && first_extensions == second_extensions
2170            && equivalent(ir, first_support, second_support, visited)
2171    }
2172
2173    equivalent(ir, first, second, &mut BTreeSet::new())
2174}
2175
2176pub(crate) fn attach_completed_intersection_pcurves(
2177    ir: &mut CadIr,
2178    graph: &Graph,
2179    prefix: &str,
2180    source_stream: cadmpeg_ir::annotations::StreamHandle,
2181    annotations: &mut AnnotationBuilder,
2182) {
2183    let loop_faces = ir
2184        .model
2185        .loops
2186        .iter()
2187        .map(|loop_| (&loop_.id, &loop_.face))
2188        .collect::<BTreeMap<_, _>>();
2189    let face_surfaces = ir
2190        .model
2191        .faces
2192        .iter()
2193        .map(|face| (&face.id, &face.surface))
2194        .collect::<BTreeMap<_, _>>();
2195    let edge_curves = ir
2196        .model
2197        .edges
2198        .iter()
2199        .filter_map(|edge| Some((&edge.id, edge.curve.as_ref()?)))
2200        .collect::<BTreeMap<_, _>>();
2201    let mut candidates =
2202        BTreeMap::<(CurveId, SurfaceId), Vec<(PcurveGeometry, [f64; 2], Option<f64>)>>::new();
2203    for procedural in &ir.model.procedural_curves {
2204        let ProceduralCurveDefinition::Intersection { context, .. } = &procedural.definition else {
2205            continue;
2206        };
2207        for side in &context.sides {
2208            let (Some(surface), Some(pcurve)) = (&side.surface, &side.pcurve) else {
2209                continue;
2210            };
2211            let values = candidates
2212                .entry((procedural.curve.clone(), surface.clone()))
2213                .or_default();
2214            let candidate = (
2215                pcurve.clone(),
2216                context.parameter_range,
2217                procedural.cache_fit_tolerance,
2218            );
2219            if !values.contains(&candidate) {
2220                values.push(candidate);
2221            }
2222        }
2223    }
2224
2225    let replacements = ir
2226        .model
2227        .coedges
2228        .iter()
2229        .filter(|coedge| coedge.pcurves.is_empty() && coedge.id.0.starts_with(prefix))
2230        .filter_map(|coedge| {
2231            let surface = loop_faces
2232                .get(&coedge.owner_loop)
2233                .and_then(|face| face_surfaces.get(*face))?;
2234            let curve = edge_curves.get(&coedge.edge)?;
2235            let [candidate] = candidates
2236                .get(&((*curve).clone(), (*surface).clone()))?
2237                .as_slice()
2238            else {
2239                return None;
2240            };
2241            pcurve_matches_edge(ir, &coedge.edge, surface, &candidate.0, candidate.2)
2242                .then(|| (coedge.id.clone(), candidate.clone()))
2243        })
2244        .collect::<Vec<_>>();
2245    for (coedge_id, (geometry, parameter_range, fit_tolerance)) in replacements {
2246        let Some(fin_xmt) = coedge_id
2247            .0
2248            .rsplit_once('#')
2249            .and_then(|(_, value)| value.parse::<u32>().ok())
2250        else {
2251            continue;
2252        };
2253        let pcurve_id = PcurveId(format!("{prefix}:intersection-pcurve-completed#{fin_xmt}"));
2254        if ir.model.pcurves.iter().any(|pcurve| pcurve.id == pcurve_id) {
2255            continue;
2256        }
2257        let source_offset = graph.get(17, fin_xmt).map_or(0, |node| node.pos as u64);
2258        annotations
2259            .note(&pcurve_id, source_stream, source_offset)
2260            .tag("INTERSECTION_PCURVE");
2261        annotations.derived(&pcurve_id, "geometry");
2262        annotations.derived(&pcurve_id, "parameter_range");
2263        if fit_tolerance.is_some() {
2264            annotations.derived(&pcurve_id, "fit_tolerance");
2265        }
2266        ir.model.pcurves.push(Pcurve {
2267            id: pcurve_id.clone(),
2268            geometry,
2269            wrapper_reversed: None,
2270            native_tail_flags: None,
2271            parameter_range: Some(parameter_range),
2272            fit_tolerance,
2273        });
2274        if let Some(coedge) = ir
2275            .model
2276            .coedges
2277            .iter_mut()
2278            .find(|coedge| coedge.id == coedge_id && coedge.pcurves.is_empty())
2279        {
2280            coedge.pcurves.push(cadmpeg_ir::topology::PcurveUse {
2281                pcurve: pcurve_id,
2282                isoparametric: None,
2283                parameter_range: None,
2284            });
2285        }
2286    }
2287}
2288
2289fn decoded_surface_point(ir: &CadIr, surface: &SurfaceId, u: f64, v: f64) -> Option<Point3> {
2290    decoded_surface_point_inner(ir, surface, u, v, 0)
2291}
2292
2293fn decoded_surface_point_inner(
2294    ir: &CadIr,
2295    surface: &SurfaceId,
2296    u: f64,
2297    v: f64,
2298    depth: usize,
2299) -> Option<Point3> {
2300    (depth < 32).then_some(())?;
2301    model_surface_point_by_id(ir, surface, u, v)
2302        .or_else(|| blend_surface_point_inner(ir, surface, u, v, depth + 1))
2303}
2304
2305#[cfg(test)]
2306pub(crate) fn blend_surface_parameters(
2307    ir: &CadIr,
2308    surface: &SurfaceId,
2309    point: Point3,
2310    seed: Option<Point2>,
2311) -> Option<Point2> {
2312    blend_surface_parameters_inner(ir, surface, point, seed, None, BlendParameterGrid::Build, 0)
2313}
2314
2315pub(crate) fn blend_surface_parameters_for_fit(
2316    ir: &CadIr,
2317    surface: &SurfaceId,
2318    point: Point3,
2319    seed: Option<Point2>,
2320    fit_tolerance: f64,
2321) -> Option<Point2> {
2322    blend_surface_parameters_for_fit_with_grid(
2323        ir,
2324        surface,
2325        point,
2326        seed,
2327        fit_tolerance,
2328        BlendParameterGrid::Build,
2329    )
2330}
2331
2332#[derive(Clone, Copy)]
2333enum BlendParameterGrid {
2334    Build,
2335    Disabled,
2336}
2337
2338fn blend_surface_parameters_for_fit_with_grid(
2339    ir: &CadIr,
2340    surface: &SurfaceId,
2341    point: Point3,
2342    seed: Option<Point2>,
2343    fit_tolerance: f64,
2344    grid: BlendParameterGrid,
2345) -> Option<Point2> {
2346    blend_surface_parameters_inner(ir, surface, point, seed, Some(fit_tolerance), grid, 0)
2347}
2348
2349fn blend_surface_parameters_inner(
2350    ir: &CadIr,
2351    surface: &SurfaceId,
2352    point: Point3,
2353    seed: Option<Point2>,
2354    fit_tolerance: Option<f64>,
2355    grid: BlendParameterGrid,
2356    depth: usize,
2357) -> Option<Point2> {
2358    (depth < 32).then_some(())?;
2359    let (_, spine, _, _) = blend_surface_definition(ir, surface)?;
2360    if let (Some(seed), Some(fit_tolerance)) = (seed, fit_tolerance) {
2361        if let Some(parameters) =
2362            refine_blend_surface_parameters(ir, surface, point, seed, depth + 1).filter(
2363                |parameters| {
2364                    blend_surface_point_inner(ir, surface, parameters.u, parameters.v, depth + 1)
2365                        .is_some_and(|candidate| point_distance(candidate, point) <= fit_tolerance)
2366                },
2367            )
2368        {
2369            return Some(parameters);
2370        }
2371    }
2372    if let Some(fit_tolerance) = fit_tolerance {
2373        let boundary_parameters = [0usize, 1usize].map(|boundary| {
2374            blend_boundary_parameter(ir, surface, point, boundary, depth + 1).filter(|parameter| {
2375                blend_boundary_point(ir, surface, *parameter, boundary, depth + 1)
2376                    .is_some_and(|candidate| point_distance(candidate, point) <= fit_tolerance)
2377            })
2378        });
2379        if let Some((parameter, boundary)) = match boundary_parameters {
2380            [Some(parameter), None] => Some((parameter, 0usize)),
2381            [None, Some(parameter)] => Some((parameter, 1usize)),
2382            _ => None,
2383        } {
2384            return Some(Point2::new(parameter, boundary as f64));
2385        }
2386    }
2387    let angular =
2388        closest_spine_parameter(ir, &spine, point, seed.map(|seed| seed.u)).and_then(|u| {
2389            let (center, tangent, first, second, _) =
2390                blend_surface_frame(ir, surface, u, depth + 1)?;
2391            let radial = unit_vector(Vector3::new(
2392                point.x - center.x,
2393                point.y - center.y,
2394                point.z - center.z,
2395            ))?;
2396            let alpha = signed_angle(first, second, tangent);
2397            if !alpha.is_finite() || alpha.abs() <= 1.0e-12 {
2398                return None;
2399            }
2400            let theta = signed_angle(first, radial, tangent);
2401            (-2..=2)
2402                .filter_map(|turn| {
2403                    let v = (theta + f64::from(turn) * std::f64::consts::TAU) / alpha;
2404                    let candidate = blend_surface_point_inner(ir, surface, u, v, depth + 1)?;
2405                    let branch_distance = seed.map_or(v.abs(), |seed| (v - seed.v).abs());
2406                    Some((
2407                        Point2::new(u, v),
2408                        point_distance(candidate, point),
2409                        branch_distance,
2410                    ))
2411                })
2412                .min_by(|first, second| {
2413                    if (first.1 - second.1).abs() <= 1.0e-12 {
2414                        first.2.total_cmp(&second.2)
2415                    } else {
2416                        first.1.total_cmp(&second.1)
2417                    }
2418                })
2419                .map(|(parameters, _, _)| parameters)
2420        });
2421    if let Some(initial) = angular {
2422        let parameters = refine_blend_surface_parameters(ir, surface, point, initial, depth + 1)
2423            .unwrap_or(initial);
2424        if let Some(candidate) =
2425            blend_surface_point_inner(ir, surface, parameters.u, parameters.v, depth + 1)
2426        {
2427            let distance = point_distance(candidate, point);
2428            if fit_tolerance.is_none_or(|tolerance| distance <= tolerance) {
2429                return Some(parameters);
2430            }
2431        }
2432    }
2433    let initial = match grid {
2434        BlendParameterGrid::Build => coarse_blend_surface_parameters(ir, surface, point, depth + 1),
2435        BlendParameterGrid::Disabled => None,
2436    }?;
2437    let parameters =
2438        refine_blend_surface_parameters(ir, surface, point, initial, depth + 1).unwrap_or(initial);
2439    if !(0.0..=1.0).contains(&parameters.v) {
2440        return None;
2441    }
2442    let candidate = blend_surface_point_inner(ir, surface, parameters.u, parameters.v, depth + 1)?;
2443    let distance = point_distance(candidate, point);
2444    fit_tolerance
2445        .is_none_or(|tolerance| distance <= tolerance)
2446        .then_some(parameters)
2447}
2448
2449pub(crate) fn coarse_blend_surface_parameters(
2450    ir: &CadIr,
2451    surface: &SurfaceId,
2452    point: Point3,
2453    depth: usize,
2454) -> Option<Point2> {
2455    let grid = blend_surface_parameter_grid(ir, surface, depth)?;
2456    closest_blend_surface_grid_parameters(&grid, point)
2457}
2458
2459fn blend_surface_parameter_grid(
2460    ir: &CadIr,
2461    surface: &SurfaceId,
2462    depth: usize,
2463) -> Option<Vec<(Point2, Point3)>> {
2464    (depth < 32).then_some(())?;
2465    let (_, spine, _, _) = blend_surface_definition(ir, surface)?;
2466    let curve = ir.model.curves.iter().find(|curve| curve.id == spine)?;
2467    let CurveGeometry::Nurbs(nurbs) = &curve.geometry else {
2468        return None;
2469    };
2470    let degree = usize::try_from(nurbs.degree).ok()?;
2471    let count = nurbs.control_points.len();
2472    let domain = [*nurbs.knots.get(degree)?, *nurbs.knots.get(count)?];
2473    if !domain.into_iter().all(f64::is_finite) || domain[0] >= domain[1] {
2474        return None;
2475    }
2476    let mut grid = Vec::with_capacity(9 * 5);
2477    for u_index in 0..=8 {
2478        let u = domain[0] + (domain[1] - domain[0]) * f64::from(u_index) / 8.0;
2479        let frame = blend_surface_frame(ir, surface, u, depth + 1);
2480        for v_index in 0..=4 {
2481            let parameters = Point2::new(u, f64::from(v_index) / 4.0);
2482            let point = match v_index {
2483                0 => blend_boundary_point(ir, surface, u, 0, depth + 1),
2484                4 => blend_boundary_point(ir, surface, u, 1, depth + 1),
2485                _ => frame.map(|frame| blend_surface_point_from_frame(frame, parameters.v)),
2486            };
2487            let Some(point) = point else {
2488                continue;
2489            };
2490            grid.push((parameters, point));
2491        }
2492    }
2493    (!grid.is_empty()).then_some(grid)
2494}
2495
2496fn closest_blend_surface_grid_parameters(
2497    grid: &[(Point2, Point3)],
2498    point: Point3,
2499) -> Option<Point2> {
2500    grid.iter()
2501        .min_by(|(_, first), (_, second)| {
2502            point_distance(*first, point).total_cmp(&point_distance(*second, point))
2503        })
2504        .map(|(parameters, _)| *parameters)
2505}
2506
2507fn blend_surface_parameters_from_grid_for_fit(
2508    ir: &CadIr,
2509    surface: &SurfaceId,
2510    point: Point3,
2511    fit_tolerance: f64,
2512    grid: &[(Point2, Point3)],
2513) -> Option<Point2> {
2514    let initial = closest_blend_surface_grid_parameters(grid, point)?;
2515    let parameters =
2516        refine_blend_surface_parameters(ir, surface, point, initial, 0).unwrap_or(initial);
2517    (0.0..=1.0).contains(&parameters.v).then_some(())?;
2518    let candidate = blend_surface_point_inner(ir, surface, parameters.u, parameters.v, 0)?;
2519    (point_distance(candidate, point) <= fit_tolerance).then_some(parameters)
2520}
2521
2522pub(crate) fn refine_blend_surface_parameters(
2523    ir: &CadIr,
2524    surface: &SurfaceId,
2525    point: Point3,
2526    mut parameters: Point2,
2527    depth: usize,
2528) -> Option<Point2> {
2529    (depth < 32).then_some(())?;
2530    let (_, spine, _, _) = blend_surface_definition(ir, surface)?;
2531    let u_domain = ir
2532        .model
2533        .curves
2534        .iter()
2535        .find(|curve| curve.id == spine)
2536        .and_then(|curve| match &curve.geometry {
2537            CurveGeometry::Nurbs(nurbs) => {
2538                let degree = usize::try_from(nurbs.degree).ok()?;
2539                let count = nurbs.control_points.len();
2540                Some([*nurbs.knots.get(degree)?, *nurbs.knots.get(count)?])
2541            }
2542            _ => None,
2543        });
2544    if let Some(domain) = u_domain {
2545        parameters.u = parameters.u.clamp(domain[0], domain[1]);
2546    }
2547    let squared_distance = |candidate: Point3| {
2548        (candidate.x - point.x).powi(2)
2549            + (candidate.y - point.y).powi(2)
2550            + (candidate.z - point.z).powi(2)
2551    };
2552    for _ in 0..16 {
2553        let position =
2554            blend_surface_point_inner(ir, surface, parameters.u, parameters.v, depth + 1)?;
2555        let residual = Vector3::new(
2556            position.x - point.x,
2557            position.y - point.y,
2558            position.z - point.z,
2559        );
2560        let current_distance = squared_distance(position);
2561        let u_step = parameter_derivative_step(parameters.u, u_domain);
2562        let v_step = parameter_derivative_step(parameters.v, None);
2563        let derivative = |along_u: bool, step: f64| {
2564            let mut before = parameters;
2565            let mut after = parameters;
2566            if along_u {
2567                before.u -= step;
2568                after.u += step;
2569                if let Some(domain) = u_domain {
2570                    before.u = before.u.clamp(domain[0], domain[1]);
2571                    after.u = after.u.clamp(domain[0], domain[1]);
2572                }
2573            } else {
2574                before.v -= step;
2575                after.v += step;
2576            }
2577            let width = if along_u {
2578                after.u - before.u
2579            } else {
2580                after.v - before.v
2581            };
2582            if !width.is_finite() || width == 0.0 {
2583                return None;
2584            }
2585            let first = blend_surface_point_inner(ir, surface, before.u, before.v, depth + 1)?;
2586            let second = blend_surface_point_inner(ir, surface, after.u, after.v, depth + 1)?;
2587            Some(Vector3::new(
2588                (second.x - first.x) / width,
2589                (second.y - first.y) / width,
2590                (second.z - first.z) / width,
2591            ))
2592        };
2593        let du = derivative(true, u_step)?;
2594        let dv = derivative(false, v_step)?;
2595        let Some((step_u, step_v)) = least_squares_step(du, dv, residual) else {
2596            break;
2597        };
2598        let mut scale = 1.0;
2599        let mut accepted = None;
2600        for _ in 0..8 {
2601            let mut candidate =
2602                Point2::new(parameters.u - scale * step_u, parameters.v - scale * step_v);
2603            if let Some(domain) = u_domain {
2604                candidate.u = candidate.u.clamp(domain[0], domain[1]);
2605            }
2606            if let Some(position) =
2607                blend_surface_point_inner(ir, surface, candidate.u, candidate.v, depth + 1)
2608            {
2609                if squared_distance(position) < current_distance {
2610                    accepted = Some(candidate);
2611                    break;
2612                }
2613            }
2614            scale *= 0.5;
2615        }
2616        let Some(candidate) = accepted else {
2617            break;
2618        };
2619        let converged = (candidate.u - parameters.u).abs() <= 1.0e-12 * (1.0 + parameters.u.abs())
2620            && (candidate.v - parameters.v).abs() <= 1.0e-12 * (1.0 + parameters.v.abs());
2621        parameters = candidate;
2622        if converged {
2623            break;
2624        }
2625    }
2626    Some(parameters)
2627}
2628
2629#[cfg(test)]
2630pub(crate) fn blend_surface_point(
2631    ir: &CadIr,
2632    surface: &SurfaceId,
2633    u: f64,
2634    v: f64,
2635) -> Option<Point3> {
2636    blend_surface_point_inner(ir, surface, u, v, 0)
2637}
2638
2639fn blend_surface_point_inner(
2640    ir: &CadIr,
2641    surface: &SurfaceId,
2642    u: f64,
2643    v: f64,
2644    depth: usize,
2645) -> Option<Point3> {
2646    (depth < 32).then_some(())?;
2647    if v.to_bits() == 0.0f64.to_bits() {
2648        return blend_boundary_point(ir, surface, u, 0, depth + 1);
2649    }
2650    if v.to_bits() == 1.0f64.to_bits() {
2651        return blend_boundary_point(ir, surface, u, 1, depth + 1);
2652    }
2653    let frame = blend_surface_frame(ir, surface, u, depth + 1)?;
2654    Some(blend_surface_point_from_frame(frame, v))
2655}
2656
2657type BlendSurfaceFrame = (Point3, Vector3, Vector3, Vector3, f64);
2658
2659fn blend_surface_point_from_frame(
2660    (center, tangent, first, second, radius): BlendSurfaceFrame,
2661    v: f64,
2662) -> Point3 {
2663    let alpha = signed_angle(first, second, tangent);
2664    let radial = rodrigues_rotate(first, tangent, v * alpha);
2665    Point3::new(
2666        center.x + radius * radial.x,
2667        center.y + radius * radial.y,
2668        center.z + radius * radial.z,
2669    )
2670}
2671
2672fn blend_surface_frame(
2673    ir: &CadIr,
2674    surface: &SurfaceId,
2675    u: f64,
2676    depth: usize,
2677) -> Option<BlendSurfaceFrame> {
2678    (depth < 32).then_some(())?;
2679    let (supports, spine, radius, _) = blend_surface_definition(ir, surface)?;
2680    let center = model_curve_point(ir, &spine, u)?;
2681    let tangent = model_curve_tangent(ir, &spine, u)?;
2682    let first = spine_contact_direction(ir, &supports[0], &spine, u, center, radius, depth + 1)
2683        .or_else(|| surface_contact_direction(ir, &supports[0], center, depth + 1))?;
2684    let second = spine_contact_direction(ir, &supports[1], &spine, u, center, radius, depth + 1)
2685        .or_else(|| surface_contact_direction(ir, &supports[1], center, depth + 1))?;
2686    Some((center, tangent, first, second, radius))
2687}
2688
2689fn spine_contact_direction(
2690    ir: &CadIr,
2691    support: &SurfaceId,
2692    spine: &CurveId,
2693    parameter: f64,
2694    center: Point3,
2695    radius: f64,
2696    depth: usize,
2697) -> Option<Vector3> {
2698    let contact = spine_contact_point(ir, support, spine, parameter, radius, depth + 1)?;
2699    unit_vector(Vector3::new(
2700        contact.x - center.x,
2701        contact.y - center.y,
2702        contact.z - center.z,
2703    ))
2704}
2705
2706fn blend_boundary_point(
2707    ir: &CadIr,
2708    surface: &SurfaceId,
2709    parameter: f64,
2710    boundary: usize,
2711    depth: usize,
2712) -> Option<Point3> {
2713    (depth < 32).then_some(())?;
2714    let (supports, spine, radius, _) = blend_surface_definition(ir, surface)?;
2715    spine_contact_point(
2716        ir,
2717        supports.get(boundary)?,
2718        &spine,
2719        parameter,
2720        radius,
2721        depth + 1,
2722    )
2723}
2724
2725fn blend_boundary_parameter(
2726    ir: &CadIr,
2727    surface: &SurfaceId,
2728    point: Point3,
2729    boundary: usize,
2730    depth: usize,
2731) -> Option<f64> {
2732    (depth < 32).then_some(())?;
2733    let (supports, spine, radius, _) = blend_surface_definition(ir, surface)?;
2734    let support = supports.get(boundary)?;
2735    let carrier = ir
2736        .model
2737        .surfaces
2738        .iter()
2739        .find(|candidate| &candidate.id == support)?;
2740    let uv = match &carrier.geometry {
2741        SurfaceGeometry::Nurbs(nurbs) => nurbs_parameters(nurbs, point, None),
2742        SurfaceGeometry::Procedural { .. } => offset_surface_parameters(ir, support, point, None),
2743        geometry => analytic_surface_parameters(geometry, point),
2744    }?;
2745    let pcurve = spine_contact_pcurve(ir, support, &spine, radius, depth + 1)?;
2746    closest_pcurve_parameter(pcurve, uv)
2747}
2748
2749fn blend_boundary_parameter_from_support_pcurve(
2750    ir: &CadIr,
2751    blend: &SurfaceId,
2752    support: &SurfaceId,
2753    support_pcurve: &PcurveGeometry,
2754    curve_parameter: f64,
2755    point: Point3,
2756    fit_tolerance: f64,
2757) -> Option<Point2> {
2758    let (supports, spine, radius, _) = blend_surface_definition(ir, blend)?;
2759    let boundary = supports
2760        .iter()
2761        .position(|candidate| parameterization_equivalent_surfaces(ir, candidate, support))?;
2762    if supports
2763        .iter()
2764        .filter(|candidate| parameterization_equivalent_surfaces(ir, candidate, support))
2765        .count()
2766        != 1
2767    {
2768        return None;
2769    }
2770    let support_uv = pcurve_uv(support_pcurve, curve_parameter)?;
2771    let contact_pcurve = spine_contact_pcurve(ir, support, &spine, radius, 0)?;
2772    let parameter = closest_pcurve_parameter(contact_pcurve, support_uv)?;
2773    blend_boundary_point(ir, blend, parameter, boundary, 0)
2774        .filter(|candidate| point_distance(*candidate, point) <= fit_tolerance)
2775        .map(|_| Point2::new(parameter, boundary as f64))
2776}
2777
2778pub(crate) fn closest_pcurve_parameter(pcurve: &PcurveGeometry, point: Point2) -> Option<f64> {
2779    let PcurveGeometry::Nurbs {
2780        degree,
2781        knots,
2782        control_points,
2783        weights,
2784        ..
2785    } = pcurve
2786    else {
2787        return None;
2788    };
2789    let degree = usize::try_from(*degree).ok()?;
2790    let count = control_points.len();
2791    if count <= degree || knots.len() != count.checked_add(degree)?.checked_add(1)? {
2792        return None;
2793    }
2794    let domain = [*knots.get(degree)?, *knots.get(count)?];
2795    if !domain[0].is_finite() || !domain[1].is_finite() || domain[0] >= domain[1] {
2796        return None;
2797    }
2798    if degree != 1 || weights.is_some() {
2799        let squared_distance = |parameter| {
2800            let position = pcurve_uv(pcurve, parameter)?;
2801            Some((position.u - point.u).powi(2) + (position.v - point.v).powi(2))
2802        };
2803        let samples = knot_domain_samples(knots, degree, domain);
2804        let distances = samples
2805            .iter()
2806            .map(|parameter| squared_distance(*parameter))
2807            .collect::<Option<Vec<_>>>()?;
2808        let mut best = samples[0];
2809        let mut best_distance = distances[0];
2810        for (index, &distance) in distances.iter().enumerate() {
2811            if distance < best_distance {
2812                best = samples[index];
2813                best_distance = distance;
2814            }
2815            if index > 0
2816                && index + 1 < samples.len()
2817                && distance <= distances[index - 1]
2818                && distance <= distances[index + 1]
2819            {
2820                let (parameter, distance) = golden_section_minimum(
2821                    samples[index - 1],
2822                    samples[index + 1],
2823                    &squared_distance,
2824                )?;
2825                if distance < best_distance {
2826                    best = parameter;
2827                    best_distance = distance;
2828                }
2829            }
2830        }
2831        return Some(best);
2832    }
2833    let mut candidates = control_points
2834        .windows(2)
2835        .enumerate()
2836        .filter_map(|(index, segment)| {
2837            let start = segment[0];
2838            let end = segment[1];
2839            let direction = Point2::new(end.u - start.u, end.v - start.v);
2840            let squared_length = direction.u * direction.u + direction.v * direction.v;
2841            if !squared_length.is_finite() || squared_length == 0.0 {
2842                return None;
2843            }
2844            let fraction = (((point.u - start.u) * direction.u
2845                + (point.v - start.v) * direction.v)
2846                / squared_length)
2847                .clamp(0.0, 1.0);
2848            let span_start = *knots.get(index + 1)?;
2849            let span_end = *knots.get(index + 2)?;
2850            if !span_start.is_finite() || !span_end.is_finite() || span_start >= span_end {
2851                return None;
2852            }
2853            let projected = Point2::new(
2854                start.u + fraction * direction.u,
2855                start.v + fraction * direction.v,
2856            );
2857            let squared_distance =
2858                (projected.u - point.u).powi(2) + (projected.v - point.v).powi(2);
2859            Some((
2860                span_start + fraction * (span_end - span_start),
2861                squared_distance,
2862            ))
2863        });
2864    let first = candidates.next()?;
2865    let best = candidates.fold(first, |best, candidate| {
2866        if candidate.1 < best.1 {
2867            candidate
2868        } else {
2869            best
2870        }
2871    });
2872    Some(best.0)
2873}
2874
2875fn spine_contact_point(
2876    ir: &CadIr,
2877    support: &SurfaceId,
2878    spine: &CurveId,
2879    parameter: f64,
2880    radius: f64,
2881    depth: usize,
2882) -> Option<Point3> {
2883    (depth < 32).then_some(())?;
2884    let pcurve = spine_contact_pcurve(ir, support, spine, radius, depth + 1)?;
2885    let uv = pcurve_uv(pcurve, parameter)?;
2886    decoded_surface_point_inner(ir, support, uv.u, uv.v, depth + 1)
2887}
2888
2889fn spine_contact_pcurve<'a>(
2890    ir: &'a CadIr,
2891    support: &SurfaceId,
2892    spine: &CurveId,
2893    radius: f64,
2894    depth: usize,
2895) -> Option<&'a PcurveGeometry> {
2896    (depth < 32).then_some(())?;
2897    let procedural = ir.model.procedural_curves.iter().find(|candidate| {
2898        candidate.curve == *spine
2899            && matches!(
2900                candidate.definition,
2901                ProceduralCurveDefinition::Intersection { .. }
2902            )
2903    })?;
2904    let ProceduralCurveDefinition::Intersection { context, .. } = &procedural.definition else {
2905        unreachable!("definition selected above");
2906    };
2907    let candidates = context.sides.iter().filter_map(|side| {
2908        let side_surface = side.surface.as_ref()?;
2909        let pcurve = side.pcurve.as_ref()?;
2910        let offset = constant_surface_offset_between(ir, support, side_surface, depth + 1)?;
2911        if !blend_contact_offset_matches(0.0, offset, radius) {
2912            return None;
2913        }
2914        Some(pcurve)
2915    });
2916    let candidates = candidates.collect::<Vec<_>>();
2917    let [pcurve] = candidates.as_slice() else {
2918        return None;
2919    };
2920    Some(*pcurve)
2921}
2922
2923pub(crate) fn constant_surface_offset_between(
2924    ir: &CadIr,
2925    support: &SurfaceId,
2926    offset_surface: &SurfaceId,
2927    depth: usize,
2928) -> Option<f64> {
2929    let (support_base, support_offset) = surface_offset_lineage(ir, support, depth + 1)?;
2930    let (offset_base, offset_distance) = surface_offset_lineage(ir, offset_surface, depth + 1)?;
2931    if support_base == offset_base {
2932        return Some(offset_distance - support_offset);
2933    }
2934    let support_geometry = &ir
2935        .model
2936        .surfaces
2937        .iter()
2938        .find(|surface| surface.id == support_base)?
2939        .geometry;
2940    let offset_geometry = &ir
2941        .model
2942        .surfaces
2943        .iter()
2944        .find(|surface| surface.id == offset_base)?
2945        .geometry;
2946    let base_offset = analytic_surface_offset(support_geometry, offset_geometry)
2947        .or_else(|| blend_surface_offset(ir, &support_base, &offset_base, depth + 1))?;
2948    Some(base_offset + offset_distance - support_offset)
2949}
2950
2951fn blend_surface_offset(
2952    ir: &CadIr,
2953    support: &SurfaceId,
2954    offset: &SurfaceId,
2955    depth: usize,
2956) -> Option<f64> {
2957    (depth < 32).then_some(())?;
2958    let (support_carriers, support_spine, support_radius, support_reversed) =
2959        blend_surface_definition(ir, support)?;
2960    let (offset_carriers, offset_spine, offset_radius, offset_reversed) =
2961        blend_surface_definition(ir, offset)?;
2962    (support_spine == offset_spine).then_some(())?;
2963
2964    let distance = offset_radius - support_radius;
2965    let magnitude = distance.abs();
2966    let matches = [[0usize, 1usize], [1usize, 0usize]]
2967        .into_iter()
2968        .filter(|permutation| {
2969            permutation
2970                .iter()
2971                .enumerate()
2972                .all(|(support_index, &offset_index)| {
2973                    support_reversed[support_index] == offset_reversed[offset_index]
2974                        && constant_surface_offset_between(
2975                            ir,
2976                            &support_carriers[support_index],
2977                            &offset_carriers[offset_index],
2978                            depth + 1,
2979                        )
2980                        .is_some_and(|carrier_distance| {
2981                            blend_contact_offset_matches(0.0, carrier_distance, magnitude)
2982                        })
2983                })
2984        })
2985        .count();
2986    (matches == 1).then_some(distance)
2987}
2988
2989pub(crate) fn analytic_surface_offset(
2990    support: &SurfaceGeometry,
2991    offset: &SurfaceGeometry,
2992) -> Option<f64> {
2993    match (support, offset) {
2994        (
2995            SurfaceGeometry::Plane {
2996                origin: support_origin,
2997                normal: support_normal,
2998                u_axis: support_u,
2999            },
3000            SurfaceGeometry::Plane {
3001                origin: offset_origin,
3002                normal: offset_normal,
3003                u_axis: offset_u,
3004            },
3005        ) if support_normal == offset_normal && support_u == offset_u => {
3006            let delta = Vector3::new(
3007                offset_origin.x - support_origin.x,
3008                offset_origin.y - support_origin.y,
3009                offset_origin.z - support_origin.z,
3010            );
3011            let distance = dot_vector(delta, *support_normal);
3012            let residual = Vector3::new(
3013                delta.x - distance * support_normal.x,
3014                delta.y - distance * support_normal.y,
3015                delta.z - distance * support_normal.z,
3016            );
3017            let scale = [
3018                support_origin.x,
3019                support_origin.y,
3020                support_origin.z,
3021                offset_origin.x,
3022                offset_origin.y,
3023                offset_origin.z,
3024                distance,
3025            ]
3026            .into_iter()
3027            .fold(1.0_f64, |scale, value| scale.max(value.abs()));
3028            let tolerance = 64.0 * f64::EPSILON * scale;
3029            (dot_vector(residual, residual) <= tolerance * tolerance).then_some(distance)
3030        }
3031        (
3032            SurfaceGeometry::Cylinder {
3033                origin: support_origin,
3034                axis: support_axis,
3035                ref_direction: support_ref,
3036                radius: support_radius,
3037            },
3038            SurfaceGeometry::Cylinder {
3039                origin: offset_origin,
3040                axis: offset_axis,
3041                ref_direction: offset_ref,
3042                radius: offset_radius,
3043            },
3044        ) if support_origin == offset_origin
3045            && support_axis == offset_axis
3046            && support_ref == offset_ref =>
3047        {
3048            Some(offset_radius - support_radius)
3049        }
3050        (
3051            SurfaceGeometry::Cone {
3052                origin: support_origin,
3053                axis: support_axis,
3054                ref_direction: support_ref,
3055                radius: support_radius,
3056                ratio: support_ratio,
3057                half_angle: support_angle,
3058            },
3059            SurfaceGeometry::Cone {
3060                origin: offset_origin,
3061                axis: offset_axis,
3062                ref_direction: offset_ref,
3063                radius: offset_radius,
3064                ratio: offset_ratio,
3065                half_angle: offset_angle,
3066            },
3067        ) if support_axis == offset_axis
3068            && support_ref == offset_ref
3069            && support_ratio.to_bits() == 1.0_f64.to_bits()
3070            && offset_ratio.to_bits() == 1.0_f64.to_bits()
3071            && support_angle.to_bits() == offset_angle.to_bits() =>
3072        {
3073            let delta = Vector3::new(
3074                offset_origin.x - support_origin.x,
3075                offset_origin.y - support_origin.y,
3076                offset_origin.z - support_origin.z,
3077            );
3078            let axial_delta = dot_vector(delta, *support_axis);
3079            let residual = Vector3::new(
3080                delta.x - axial_delta * support_axis.x,
3081                delta.y - axial_delta * support_axis.y,
3082                delta.z - axial_delta * support_axis.z,
3083            );
3084            let radial_delta = offset_radius - support_radius;
3085            let distance = radial_delta * support_angle.cos() - axial_delta * support_angle.sin();
3086            let tangent_residual =
3087                radial_delta * support_angle.sin() + axial_delta * support_angle.cos();
3088            let scale = [
3089                support_origin.x,
3090                support_origin.y,
3091                support_origin.z,
3092                offset_origin.x,
3093                offset_origin.y,
3094                offset_origin.z,
3095                *support_radius,
3096                *offset_radius,
3097                axial_delta,
3098                distance,
3099                tangent_residual,
3100            ]
3101            .into_iter()
3102            .fold(1.0_f64, |scale, value| scale.max(value.abs()));
3103            let tolerance = 64.0 * f64::EPSILON * scale;
3104            (distance.is_finite()
3105                && dot_vector(residual, residual) <= tolerance * tolerance
3106                && tangent_residual.abs() <= tolerance)
3107                .then_some(distance)
3108        }
3109        (
3110            SurfaceGeometry::Sphere {
3111                center: support_center,
3112                axis: support_axis,
3113                ref_direction: support_ref,
3114                radius: support_radius,
3115            },
3116            SurfaceGeometry::Sphere {
3117                center: offset_center,
3118                axis: offset_axis,
3119                ref_direction: offset_ref,
3120                radius: offset_radius,
3121            },
3122        ) if support_center == offset_center
3123            && support_axis == offset_axis
3124            && support_ref == offset_ref
3125            && support_radius.signum().to_bits() == offset_radius.signum().to_bits() =>
3126        {
3127            Some((offset_radius - support_radius) * support_radius.signum())
3128        }
3129        (
3130            SurfaceGeometry::Torus {
3131                center: support_center,
3132                axis: support_axis,
3133                ref_direction: support_ref,
3134                major_radius: support_major,
3135                minor_radius: support_minor,
3136            },
3137            SurfaceGeometry::Torus {
3138                center: offset_center,
3139                axis: offset_axis,
3140                ref_direction: offset_ref,
3141                major_radius: offset_major,
3142                minor_radius: offset_minor,
3143            },
3144        ) if support_center == offset_center
3145            && support_axis == offset_axis
3146            && support_ref == offset_ref
3147            && support_major.to_bits() == offset_major.to_bits()
3148            && support_minor.signum().to_bits() == offset_minor.signum().to_bits()
3149            && *support_major > support_minor.abs()
3150            && *offset_major > offset_minor.abs() =>
3151        {
3152            Some((offset_minor - support_minor) * support_minor.signum())
3153        }
3154        _ => None,
3155    }
3156}
3157
3158pub(crate) fn blend_contact_offset_matches(
3159    support_offset: f64,
3160    spine_side_offset: f64,
3161    radius: f64,
3162) -> bool {
3163    let actual = (spine_side_offset - support_offset).abs();
3164    let expected = radius.abs();
3165    let scale = actual.max(expected).max(1.0);
3166    actual.is_finite()
3167        && expected.is_finite()
3168        && (actual - expected).abs() <= 64.0 * f64::EPSILON * scale
3169}
3170
3171fn surface_offset_lineage(
3172    ir: &CadIr,
3173    surface: &SurfaceId,
3174    depth: usize,
3175) -> Option<(SurfaceId, f64)> {
3176    (depth < 32).then_some(())?;
3177    let carrier = ir
3178        .model
3179        .surfaces
3180        .iter()
3181        .find(|candidate| &candidate.id == surface)?;
3182    let SurfaceGeometry::Procedural { construction } = &carrier.geometry else {
3183        return Some((surface.clone(), 0.0));
3184    };
3185    let procedural = ir
3186        .model
3187        .procedural_surfaces
3188        .iter()
3189        .find(|candidate| candidate.id == *construction && candidate.surface == *surface)?;
3190    let ProceduralSurfaceDefinition::Offset {
3191        support, distance, ..
3192    } = &procedural.definition
3193    else {
3194        return Some((surface.clone(), 0.0));
3195    };
3196    let (base, accumulated) = surface_offset_lineage(ir, support, depth + 1)?;
3197    Some((base, accumulated + distance))
3198}
3199
3200fn blend_surface_definition(
3201    ir: &CadIr,
3202    surface: &SurfaceId,
3203) -> Option<([SurfaceId; 2], CurveId, f64, [bool; 2])> {
3204    let carrier = ir
3205        .model
3206        .surfaces
3207        .iter()
3208        .find(|candidate| &candidate.id == surface)?;
3209    let SurfaceGeometry::Procedural { construction } = &carrier.geometry else {
3210        return None;
3211    };
3212    let procedural = ir
3213        .model
3214        .procedural_surfaces
3215        .iter()
3216        .find(|candidate| &candidate.id == construction && &candidate.surface == surface)?;
3217    let ProceduralSurfaceDefinition::Blend {
3218        supports: [Some(first), Some(second)],
3219        spine: Some(spine),
3220        radius: BlendRadiusLaw::Constant { signed_radius },
3221        cross_section: BlendCrossSection::Circular,
3222        ..
3223    } = &procedural.definition
3224    else {
3225        return None;
3226    };
3227    let radius = signed_radius.abs();
3228    (radius.is_finite() && radius > 0.0).then(|| {
3229        (
3230            [first.surface.clone(), second.surface.clone()],
3231            spine.clone(),
3232            radius,
3233            [first.reversed, second.reversed],
3234        )
3235    })
3236}
3237
3238fn surface_contact_direction(
3239    ir: &CadIr,
3240    surface: &SurfaceId,
3241    center: Point3,
3242    depth: usize,
3243) -> Option<Vector3> {
3244    (depth < 32).then_some(())?;
3245    let carrier = ir
3246        .model
3247        .surfaces
3248        .iter()
3249        .find(|candidate| &candidate.id == surface)?;
3250    let parameters = match &carrier.geometry {
3251        SurfaceGeometry::Nurbs(nurbs) => nurbs_parameters(nurbs, center, None),
3252        SurfaceGeometry::Procedural { .. } => offset_surface_parameters(ir, surface, center, None)
3253            .or_else(|| {
3254                blend_surface_parameters_inner(
3255                    ir,
3256                    surface,
3257                    center,
3258                    None,
3259                    None,
3260                    BlendParameterGrid::Build,
3261                    depth + 1,
3262                )
3263            }),
3264        geometry => analytic_surface_parameters(geometry, center),
3265    }?;
3266    let contact = decoded_surface_point_inner(ir, surface, parameters.u, parameters.v, depth + 1)?;
3267    unit_vector(Vector3::new(
3268        contact.x - center.x,
3269        contact.y - center.y,
3270        contact.z - center.z,
3271    ))
3272}
3273
3274fn model_curve_point(ir: &CadIr, curve: &CurveId, parameter: f64) -> Option<Point3> {
3275    let carrier = ir
3276        .model
3277        .curves
3278        .iter()
3279        .find(|candidate| &candidate.id == curve)?;
3280    curve_point(&carrier.geometry, parameter)
3281}
3282
3283fn model_curve_tangent(ir: &CadIr, curve: &CurveId, parameter: f64) -> Option<Vector3> {
3284    let step = 1.0e-6 * (1.0 + parameter.abs());
3285    let center = model_curve_point(ir, curve, parameter)?;
3286    let before = model_curve_point(ir, curve, parameter - step);
3287    let after = model_curve_point(ir, curve, parameter + step);
3288    let (before, after) = match (before, after) {
3289        (Some(before), Some(after)) => (before, after),
3290        (Some(before), None) => (before, center),
3291        (None, Some(after)) => (center, after),
3292        (None, None) => return None,
3293    };
3294    unit_vector(Vector3::new(
3295        after.x - before.x,
3296        after.y - before.y,
3297        after.z - before.z,
3298    ))
3299}
3300
3301pub(crate) fn closest_spine_parameter(
3302    ir: &CadIr,
3303    curve: &CurveId,
3304    point: Point3,
3305    seed: Option<f64>,
3306) -> Option<f64> {
3307    let carrier = ir
3308        .model
3309        .curves
3310        .iter()
3311        .find(|candidate| &candidate.id == curve)?;
3312    match &carrier.geometry {
3313        CurveGeometry::Line { origin, direction } => Some(
3314            (point.x - origin.x) * direction.x
3315                + (point.y - origin.y) * direction.y
3316                + (point.z - origin.z) * direction.z,
3317        ),
3318        CurveGeometry::Circle {
3319            center,
3320            axis,
3321            ref_direction,
3322            ..
3323        }
3324        | CurveGeometry::Ellipse {
3325            center,
3326            axis,
3327            major_direction: ref_direction,
3328            ..
3329        } => closest_periodic_analytic_curve_parameter(
3330            &carrier.geometry,
3331            *center,
3332            *axis,
3333            *ref_direction,
3334            point,
3335            seed,
3336        ),
3337        CurveGeometry::Nurbs(nurbs) => {
3338            let degree = usize::try_from(nurbs.degree).ok()?;
3339            let count = nurbs.control_points.len();
3340            let domain = [*nurbs.knots.get(degree)?, *nurbs.knots.get(count)?];
3341            if domain[0] >= domain[1] {
3342                return None;
3343            }
3344            closest_nurbs_curve_parameter(
3345                &carrier.geometry,
3346                &nurbs.knots,
3347                degree,
3348                domain,
3349                point,
3350                seed,
3351            )
3352        }
3353        _ => None,
3354    }
3355}
3356
3357fn closest_periodic_analytic_curve_parameter(
3358    geometry: &CurveGeometry,
3359    center: Point3,
3360    axis: Vector3,
3361    reference: Vector3,
3362    point: Point3,
3363    seed: Option<f64>,
3364) -> Option<f64> {
3365    let transverse = cross_vector(axis, reference);
3366    let delta = Vector3::new(point.x - center.x, point.y - center.y, point.z - center.z);
3367    let phase = dot_vector(delta, transverse).atan2(dot_vector(delta, reference));
3368    phase.is_finite().then_some(())?;
3369    let anchor = seed.map_or(phase, |seed| {
3370        phase + ((seed - phase) / std::f64::consts::TAU).round() * std::f64::consts::TAU
3371    });
3372    let lower = anchor - std::f64::consts::PI;
3373    let step = std::f64::consts::TAU / 64.0;
3374    let squared_distance = |parameter| {
3375        let position = curve_point(geometry, parameter)?;
3376        Some(
3377            (position.x - point.x).powi(2)
3378                + (position.y - point.y).powi(2)
3379                + (position.z - point.z).powi(2),
3380        )
3381    };
3382    let samples = (0..=64)
3383        .map(|index| lower + f64::from(index) * step)
3384        .collect::<Vec<_>>();
3385    let distances = samples
3386        .iter()
3387        .map(|parameter| squared_distance(*parameter))
3388        .collect::<Option<Vec<_>>>()?;
3389    let mut best_index = 0;
3390    for index in 1..distances.len() {
3391        if distances[index] < distances[best_index]
3392            || distances[index] == distances[best_index]
3393                && (samples[index] - anchor).abs() < (samples[best_index] - anchor).abs()
3394        {
3395            best_index = index;
3396        }
3397    }
3398    let bracket_center = match best_index {
3399        0 => samples[0] + std::f64::consts::TAU,
3400        64 => samples[64] - std::f64::consts::TAU,
3401        _ => samples[best_index],
3402    };
3403    let (parameter, _) = golden_section_minimum(
3404        bracket_center - step,
3405        bracket_center + step,
3406        &squared_distance,
3407    )?;
3408    Some(parameter + ((anchor - parameter) / std::f64::consts::TAU).round() * std::f64::consts::TAU)
3409}
3410
3411fn closest_nurbs_curve_parameter(
3412    geometry: &CurveGeometry,
3413    knots: &[f64],
3414    degree: usize,
3415    domain: [f64; 2],
3416    point: Point3,
3417    seed: Option<f64>,
3418) -> Option<f64> {
3419    let squared_distance = |parameter| {
3420        let position = curve_point(geometry, parameter)?;
3421        Some(
3422            (position.x - point.x).powi(2)
3423                + (position.y - point.y).powi(2)
3424                + (position.z - point.z).powi(2),
3425        )
3426    };
3427    let samples = knot_domain_samples(knots, degree, domain);
3428    let distances = samples
3429        .iter()
3430        .map(|parameter| squared_distance(*parameter))
3431        .collect::<Option<Vec<_>>>()?;
3432    let mut best = samples[0];
3433    let mut best_distance = distances[0];
3434    let mut best_seed_distance = seed.map_or(best.abs(), |seed| (best - seed).abs());
3435    let mut consider = |parameter: f64, distance: f64| {
3436        let seed_distance = seed.map_or(parameter.abs(), |seed| (parameter - seed).abs());
3437        let same_point = (distance - best_distance).abs()
3438            <= f64::EPSILON * 64.0 * distance.abs().max(best_distance.abs()).max(1.0);
3439        if distance < best_distance && !same_point
3440            || same_point && seed_distance < best_seed_distance
3441        {
3442            best = parameter;
3443            best_distance = distance;
3444            best_seed_distance = seed_distance;
3445        }
3446    };
3447    for (index, &distance) in distances.iter().enumerate() {
3448        consider(samples[index], distance);
3449        if index > 0
3450            && index + 1 < samples.len()
3451            && distance <= distances[index - 1]
3452            && distance <= distances[index + 1]
3453        {
3454            let (parameter, distance) =
3455                golden_section_minimum(samples[index - 1], samples[index + 1], &squared_distance)?;
3456            consider(parameter, distance);
3457        }
3458    }
3459    if let Some(seed) = seed {
3460        let seed = seed.clamp(domain[0], domain[1]);
3461        let insertion = samples.partition_point(|parameter| *parameter < seed);
3462        let lower = samples[insertion.saturating_sub(1)];
3463        let upper = samples[insertion.min(samples.len() - 1)];
3464        if lower < upper {
3465            let (parameter, distance) = golden_section_minimum(lower, upper, &squared_distance)?;
3466            consider(parameter, distance);
3467        } else {
3468            consider(seed, squared_distance(seed)?);
3469        }
3470    }
3471    Some(best)
3472}
3473
3474fn knot_domain_samples(knots: &[f64], degree: usize, domain: [f64; 2]) -> Vec<f64> {
3475    let subdivisions = 2 * (degree + 1).max(2);
3476    let mut samples = vec![domain[0]];
3477    for span in knots[degree..].windows(2) {
3478        let start = span[0].max(domain[0]);
3479        let end = span[1].min(domain[1]);
3480        if start >= end {
3481            continue;
3482        }
3483        for index in 1..=subdivisions {
3484            samples.push(start + (end - start) * index as f64 / subdivisions as f64);
3485        }
3486        if end >= domain[1] {
3487            break;
3488        }
3489    }
3490    samples.sort_by(f64::total_cmp);
3491    samples.dedup_by(|left, right| *left == *right);
3492    samples
3493}
3494
3495fn golden_section_minimum(
3496    mut lower: f64,
3497    mut upper: f64,
3498    value: &impl Fn(f64) -> Option<f64>,
3499) -> Option<(f64, f64)> {
3500    let ratio = (5.0_f64.sqrt() - 1.0) / 2.0;
3501    let mut left = upper - ratio * (upper - lower);
3502    let mut right = lower + ratio * (upper - lower);
3503    let mut left_value = value(left)?;
3504    let mut right_value = value(right)?;
3505    for _ in 0..64 {
3506        if left_value <= right_value {
3507            upper = right;
3508            right = left;
3509            right_value = left_value;
3510            left = upper - ratio * (upper - lower);
3511            left_value = value(left)?;
3512        } else {
3513            lower = left;
3514            left = right;
3515            left_value = right_value;
3516            right = lower + ratio * (upper - lower);
3517            right_value = value(right)?;
3518        }
3519    }
3520    if left_value <= right_value {
3521        Some((left, left_value))
3522    } else {
3523        Some((right, right_value))
3524    }
3525}
3526
3527fn signed_angle(first: Vector3, second: Vector3, axis: Vector3) -> f64 {
3528    dot_vector(cross_vector(first, second), axis).atan2(dot_vector(first, second))
3529}
3530
3531fn rodrigues_rotate(vector: Vector3, axis: Vector3, angle: f64) -> Vector3 {
3532    let cross = cross_vector(axis, vector);
3533    let dot = dot_vector(axis, vector);
3534    Vector3::new(
3535        vector.x * angle.cos() + cross.x * angle.sin() + axis.x * dot * (1.0 - angle.cos()),
3536        vector.y * angle.cos() + cross.y * angle.sin() + axis.y * dot * (1.0 - angle.cos()),
3537        vector.z * angle.cos() + cross.z * angle.sin() + axis.z * dot * (1.0 - angle.cos()),
3538    )
3539}
3540
3541pub(crate) fn offset_surface_parameters(
3542    ir: &CadIr,
3543    surface: &SurfaceId,
3544    point: Point3,
3545    seed: Option<Point2>,
3546) -> Option<Point2> {
3547    offset_surface_parameters_with_tolerance(ir, surface, point, seed, None)
3548}
3549
3550pub(crate) fn offset_surface_parameters_with_tolerance(
3551    ir: &CadIr,
3552    surface: &SurfaceId,
3553    point: Point3,
3554    seed: Option<Point2>,
3555    fit_tolerance: Option<f64>,
3556) -> Option<Point2> {
3557    let carrier = ir
3558        .model
3559        .surfaces
3560        .iter()
3561        .find(|candidate| &candidate.id == surface)?;
3562    let SurfaceGeometry::Procedural { construction } = &carrier.geometry else {
3563        return None;
3564    };
3565    let procedural = ir
3566        .model
3567        .procedural_surfaces
3568        .iter()
3569        .find(|candidate| &candidate.id == construction && &candidate.surface == surface)?;
3570    let ProceduralSurfaceDefinition::Offset { support, .. } = &procedural.definition else {
3571        return None;
3572    };
3573    let domain = surface_parameter_domain(ir, support);
3574    let mut parameters = seed
3575        .or_else(|| initial_surface_parameters(ir, support, point, None))
3576        .or_else(|| {
3577            domain.and_then(|domain| coarse_model_surface_parameters(ir, surface, point, domain))
3578        })?;
3579    clamp_surface_parameters(&mut parameters, domain);
3580    for _ in 0..32 {
3581        let position = model_surface_point_by_id(ir, surface, parameters.u, parameters.v)?;
3582        let residual = Vector3::new(
3583            position.x - point.x,
3584            position.y - point.y,
3585            position.z - point.z,
3586        );
3587        if fit_tolerance.is_some_and(|tolerance| {
3588            tolerance.is_finite()
3589                && tolerance >= 0.0
3590                && dot_vector(residual, residual) <= tolerance * tolerance
3591        }) {
3592            break;
3593        }
3594        let u_step = parameter_derivative_step(parameters.u, domain.map(|domain| domain.0));
3595        let v_step = parameter_derivative_step(parameters.v, domain.map(|domain| domain.1));
3596        let du =
3597            model_surface_derivative(ir, surface, parameters, u_step, true, domain, [None, None])?;
3598        let dv =
3599            model_surface_derivative(ir, surface, parameters, v_step, false, domain, [None, None])?;
3600        let Some((step_u, step_v)) = least_squares_step(du, dv, residual) else {
3601            break;
3602        };
3603        parameters.u -= step_u;
3604        parameters.v -= step_v;
3605        clamp_surface_parameters(&mut parameters, domain);
3606        if step_u.abs() <= 1.0e-12 * (1.0 + parameters.u.abs())
3607            && step_v.abs() <= 1.0e-12 * (1.0 + parameters.v.abs())
3608        {
3609            break;
3610        }
3611    }
3612    Some(parameters)
3613}
3614
3615fn coarse_model_surface_parameters(
3616    ir: &CadIr,
3617    surface: &SurfaceId,
3618    point: Point3,
3619    domain: ([f64; 2], [f64; 2]),
3620) -> Option<Point2> {
3621    let (u_domain, v_domain) = domain;
3622    let mut best = None;
3623    let mut best_distance = f64::INFINITY;
3624    for ui in 0..=8 {
3625        for vi in 0..=8 {
3626            let parameters = Point2::new(
3627                u_domain[0] + (u_domain[1] - u_domain[0]) * f64::from(ui) / 8.0,
3628                v_domain[0] + (v_domain[1] - v_domain[0]) * f64::from(vi) / 8.0,
3629            );
3630            let Some(candidate) =
3631                model_surface_point_by_id(ir, surface, parameters.u, parameters.v)
3632            else {
3633                continue;
3634            };
3635            let distance = (candidate.x - point.x).powi(2)
3636                + (candidate.y - point.y).powi(2)
3637                + (candidate.z - point.z).powi(2);
3638            if distance < best_distance {
3639                best = Some(parameters);
3640                best_distance = distance;
3641            }
3642        }
3643    }
3644    best
3645}
3646
3647fn initial_surface_parameters(
3648    ir: &CadIr,
3649    surface: &SurfaceId,
3650    point: Point3,
3651    seed: Option<Point2>,
3652) -> Option<Point2> {
3653    let carrier = ir
3654        .model
3655        .surfaces
3656        .iter()
3657        .find(|candidate| &candidate.id == surface)?;
3658    match &carrier.geometry {
3659        SurfaceGeometry::Nurbs(nurbs) => nurbs_parameters(nurbs, point, seed),
3660        SurfaceGeometry::Procedural { construction } => {
3661            let procedural =
3662                ir.model.procedural_surfaces.iter().find(|candidate| {
3663                    &candidate.id == construction && &candidate.surface == surface
3664                })?;
3665            let ProceduralSurfaceDefinition::Offset { support, .. } = &procedural.definition else {
3666                return None;
3667            };
3668            initial_surface_parameters(ir, support, point, seed)
3669        }
3670        geometry => analytic_surface_parameters(geometry, point),
3671    }
3672}
3673
3674fn surface_parameter_domain(ir: &CadIr, surface: &SurfaceId) -> Option<([f64; 2], [f64; 2])> {
3675    let carrier = ir
3676        .model
3677        .surfaces
3678        .iter()
3679        .find(|candidate| &candidate.id == surface)?;
3680    match &carrier.geometry {
3681        SurfaceGeometry::Nurbs(nurbs) => {
3682            let u_degree = usize::try_from(nurbs.u_degree).ok()?;
3683            let v_degree = usize::try_from(nurbs.v_degree).ok()?;
3684            let u_count = usize::try_from(nurbs.u_count).ok()?;
3685            let v_count = usize::try_from(nurbs.v_count).ok()?;
3686            Some((
3687                [*nurbs.u_knots.get(u_degree)?, *nurbs.u_knots.get(u_count)?],
3688                [*nurbs.v_knots.get(v_degree)?, *nurbs.v_knots.get(v_count)?],
3689            ))
3690        }
3691        SurfaceGeometry::Procedural { construction } => {
3692            let procedural =
3693                ir.model.procedural_surfaces.iter().find(|candidate| {
3694                    &candidate.id == construction && &candidate.surface == surface
3695                })?;
3696            let ProceduralSurfaceDefinition::Offset { support, .. } = &procedural.definition else {
3697                return None;
3698            };
3699            surface_parameter_domain(ir, support)
3700        }
3701        _ => None,
3702    }
3703}
3704
3705fn clamp_surface_parameters(parameters: &mut Point2, domain: Option<([f64; 2], [f64; 2])>) {
3706    if let Some((u_domain, v_domain)) = domain {
3707        parameters.u = parameters.u.clamp(u_domain[0], u_domain[1]);
3708        parameters.v = parameters.v.clamp(v_domain[0], v_domain[1]);
3709    }
3710}
3711
3712fn parameter_derivative_step(parameter: f64, domain: Option<[f64; 2]>) -> f64 {
3713    domain.map_or_else(
3714        || 1.0e-6 * (1.0 + parameter.abs()),
3715        |domain| 1.0e-6 * (domain[1] - domain[0]).abs().max(1.0),
3716    )
3717}
3718
3719fn model_surface_derivative(
3720    ir: &CadIr,
3721    surface: &SurfaceId,
3722    parameters: Point2,
3723    step: f64,
3724    along_u: bool,
3725    domain: Option<([f64; 2], [f64; 2])>,
3726    periods: [Option<f64>; 2],
3727) -> Option<Vector3> {
3728    let mut before = parameters;
3729    let mut after = parameters;
3730    if along_u {
3731        before.u -= step;
3732        after.u += step;
3733    } else {
3734        before.v -= step;
3735        after.v += step;
3736    }
3737    clamp_surface_parameters_with_periods(&mut before, domain, periods);
3738    clamp_surface_parameters_with_periods(&mut after, domain, periods);
3739    let width = if along_u {
3740        after.u - before.u
3741    } else {
3742        after.v - before.v
3743    };
3744    if !width.is_finite() || width == 0.0 {
3745        return None;
3746    }
3747    let first = model_surface_point_by_id(ir, surface, before.u, before.v)?;
3748    let second = model_surface_point_by_id(ir, surface, after.u, after.v)?;
3749    Some(Vector3::new(
3750        (second.x - first.x) / width,
3751        (second.y - first.y) / width,
3752        (second.z - first.z) / width,
3753    ))
3754}
3755
3756/// Continue one chart-selected surface-intersection branch in both support
3757/// parameter spaces. The chart seeds and orders the branch; corrected points
3758/// satisfy the two support surfaces rather than interpolating chart samples.
3759#[cfg(test)]
3760pub(crate) fn continue_surface_intersection_parameters(
3761    ir: &CadIr,
3762    surfaces: [&SurfaceId; 2],
3763    chart: &[Point3],
3764    fit_tolerance: f64,
3765) -> Option<[Vec<Point2>; 2]> {
3766    continue_surface_intersection_parameters_with_seeds(
3767        ir,
3768        surfaces,
3769        chart,
3770        fit_tolerance,
3771        [None, None],
3772    )
3773}
3774
3775fn continue_surface_intersection_parameters_with_seeds(
3776    ir: &CadIr,
3777    surfaces: [&SurfaceId; 2],
3778    chart: &[Point3],
3779    fit_tolerance: f64,
3780    seeds: [Option<Point2>; 2],
3781) -> Option<[Vec<Point2>; 2]> {
3782    if chart.len() < 2
3783        || surfaces[0] == surfaces[1]
3784        || !fit_tolerance.is_finite()
3785        || fit_tolerance <= 0.0
3786    {
3787        return None;
3788    }
3789    let fit_parameters = |surface: &SurfaceId, point: Point3, seed: Option<Point2>| {
3790        let geometry = &ir
3791            .model
3792            .surfaces
3793            .iter()
3794            .find(|candidate| &candidate.id == surface)?
3795            .geometry;
3796        match geometry {
3797            SurfaceGeometry::Nurbs(nurbs) => nurbs_parameters(nurbs, point, seed),
3798            SurfaceGeometry::Procedural { .. } => offset_surface_parameters_with_tolerance(
3799                ir,
3800                surface,
3801                point,
3802                seed,
3803                Some(fit_tolerance),
3804            )
3805            .or_else(|| blend_surface_parameters_for_fit(ir, surface, point, seed, fit_tolerance)),
3806            geometry => analytic_surface_parameters(geometry, point),
3807        }
3808    };
3809    let first = [
3810        fit_parameters(surfaces[0], chart[0], seeds[0])?,
3811        fit_parameters(surfaces[1], chart[0], seeds[1])?,
3812    ];
3813    let space = IntersectionParameterSpace {
3814        domains: surfaces.map(|surface| surface_parameter_domain(ir, surface)),
3815        periods: surfaces.map(|surface| surface_parameter_periods(ir, surface)),
3816    };
3817    let seed = [first[0].u, first[0].v, first[1].u, first[1].v];
3818    let first_chord = Vector3::new(
3819        chart[1].x - chart[0].x,
3820        chart[1].y - chart[0].y,
3821        chart[1].z - chart[0].z,
3822    );
3823    let seed_tangent = intersection_parameter_tangent(ir, surfaces, seed, space, first_chord)?;
3824    let mut current = correct_intersection_parameters(
3825        ir,
3826        surfaces,
3827        seed,
3828        seed_tangent,
3829        space,
3830        fit_tolerance,
3831        1.0,
3832    )?;
3833    let first_point = model_surface_point_by_id(ir, surfaces[0], current[0], current[1])?;
3834    if point_distance(first_point, chart[0]) > fit_tolerance {
3835        return None;
3836    }
3837    let mut lanes = [
3838        vec![Point2::new(current[0], current[1])],
3839        vec![Point2::new(current[2], current[3])],
3840    ];
3841
3842    for chart_pair in chart.windows(2) {
3843        let jacobian = intersection_parameter_jacobian(ir, surfaces, current, space)?;
3844        let chord = Vector3::new(
3845            chart_pair[1].x - chart_pair[0].x,
3846            chart_pair[1].y - chart_pair[0].y,
3847            chart_pair[1].z - chart_pair[0].z,
3848        );
3849        let tangent = intersection_parameter_tangent(ir, surfaces, current, space, chord)?;
3850        let spatial_tangent = Vector3::new(
3851            jacobian[0][0] * tangent[0] + jacobian[0][1] * tangent[1],
3852            jacobian[1][0] * tangent[0] + jacobian[1][1] * tangent[1],
3853            jacobian[2][0] * tangent[0] + jacobian[2][1] * tangent[1],
3854        );
3855        let target = [
3856            fit_parameters(
3857                surfaces[0],
3858                chart_pair[1],
3859                Some(Point2::new(current[0], current[1])),
3860            )?,
3861            fit_parameters(
3862                surfaces[1],
3863                chart_pair[1],
3864                Some(Point2::new(current[2], current[3])),
3865            )?,
3866        ];
3867        let mut predictor = [target[0].u, target[0].v, target[1].u, target[1].v];
3868        for (side, surface_periods) in space.periods.into_iter().enumerate() {
3869            for (coordinate, period) in surface_periods.into_iter().enumerate() {
3870                let index = side * 2 + coordinate;
3871                if let Some(period) = period {
3872                    predictor[index] =
3873                        lift_periodic_parameter(predictor[index], current[index], period);
3874                }
3875            }
3876        }
3877        let scale = (0..4)
3878            .map(|index| (predictor[index] - current[index]) * tangent[index])
3879            .sum::<f64>();
3880        if !scale.is_finite() || scale == 0.0 || dot_vector(spatial_tangent, chord) * scale <= 0.0 {
3881            return None;
3882        }
3883        let corrected = correct_intersection_parameters(
3884            ir,
3885            surfaces,
3886            predictor,
3887            tangent,
3888            space,
3889            fit_tolerance,
3890            scale,
3891        )?;
3892        let point = model_surface_point_by_id(ir, surfaces[0], corrected[0], corrected[1])?;
3893        if point_distance(point, chart_pair[1]) > fit_tolerance {
3894            return None;
3895        }
3896        current = corrected;
3897        lanes[0].push(Point2::new(current[0], current[1]));
3898        lanes[1].push(Point2::new(current[2], current[3]));
3899    }
3900    Some(lanes)
3901}
3902
3903fn lift_periodic_parameter(value: f64, reference: f64, period: f64) -> f64 {
3904    value + ((reference - value) / period).round() * period
3905}
3906
3907/// Return supported parameter periods while rejecting cyclic procedural support graphs.
3908pub(crate) fn surface_parameter_periods(ir: &CadIr, surface: &SurfaceId) -> [Option<f64>; 2] {
3909    surface_parameter_periods_inner(ir, surface, &mut BTreeSet::new())
3910}
3911
3912fn surface_parameter_periods_inner(
3913    ir: &CadIr,
3914    surface: &SurfaceId,
3915    visiting: &mut BTreeSet<SurfaceId>,
3916) -> [Option<f64>; 2] {
3917    if !visiting.insert(surface.clone()) {
3918        return [None, None];
3919    }
3920    let Some(carrier) = ir
3921        .model
3922        .surfaces
3923        .iter()
3924        .find(|candidate| &candidate.id == surface)
3925    else {
3926        visiting.remove(surface);
3927        return [None, None];
3928    };
3929    let periods = match &carrier.geometry {
3930        SurfaceGeometry::Cylinder { .. }
3931        | SurfaceGeometry::Cone { .. }
3932        | SurfaceGeometry::Sphere { .. } => [Some(std::f64::consts::TAU), None],
3933        SurfaceGeometry::Torus { .. } => [Some(std::f64::consts::TAU), Some(std::f64::consts::TAU)],
3934        SurfaceGeometry::Nurbs(nurbs) => {
3935            let period = |periodic: bool, knots: &[f64], degree: u32, count: u32| {
3936                periodic.then(|| {
3937                    let degree = usize::try_from(degree).ok()?;
3938                    let count = usize::try_from(count).ok()?;
3939                    let period = knots.get(count)? - knots.get(degree)?;
3940                    (period.is_finite() && period > 0.0).then_some(period)
3941                })?
3942            };
3943            [
3944                period(
3945                    nurbs.u_periodic,
3946                    &nurbs.u_knots,
3947                    nurbs.u_degree,
3948                    nurbs.u_count,
3949                ),
3950                period(
3951                    nurbs.v_periodic,
3952                    &nurbs.v_knots,
3953                    nurbs.v_degree,
3954                    nurbs.v_count,
3955                ),
3956            ]
3957        }
3958        SurfaceGeometry::Procedural { construction } => ir
3959            .model
3960            .procedural_surfaces
3961            .iter()
3962            .find(|candidate| &candidate.id == construction && &candidate.surface == surface)
3963            .and_then(|procedural| match &procedural.definition {
3964                ProceduralSurfaceDefinition::Offset { support, .. } => {
3965                    Some(surface_parameter_periods_inner(ir, support, visiting))
3966                }
3967                _ => None,
3968            })
3969            .unwrap_or([None, None]),
3970        _ => [None, None],
3971    };
3972    visiting.remove(surface);
3973    periods
3974}
3975
3976fn correct_intersection_parameters(
3977    ir: &CadIr,
3978    surfaces: [&SurfaceId; 2],
3979    predictor: [f64; 4],
3980    tangent: [f64; 4],
3981    space: IntersectionParameterSpace,
3982    fit_tolerance: f64,
3983    scale: f64,
3984) -> Option<[f64; 4]> {
3985    let mut corrected = predictor;
3986    clamp_intersection_parameters(&mut corrected, space);
3987    for _ in 0..32 {
3988        let first = model_surface_point_by_id(ir, surfaces[0], corrected[0], corrected[1])?;
3989        let second = model_surface_point_by_id(ir, surfaces[1], corrected[2], corrected[3])?;
3990        let residual = [
3991            first.x - second.x,
3992            first.y - second.y,
3993            first.z - second.z,
3994            (0..4)
3995                .map(|index| (corrected[index] - predictor[index]) * tangent[index])
3996                .sum(),
3997        ];
3998        let equality_error = residual[..3]
3999            .iter()
4000            .map(|value| value * value)
4001            .sum::<f64>()
4002            .sqrt();
4003        if equality_error <= fit_tolerance * 1.0e-6
4004            && residual[3].abs() <= 1.0e-11 * (1.0 + scale.abs())
4005        {
4006            return Some(corrected);
4007        }
4008        let jacobian = intersection_parameter_jacobian(ir, surfaces, corrected, space)?;
4009        let matrix = [jacobian[0], jacobian[1], jacobian[2], tangent];
4010        let step = solve_4x4(matrix, residual.map(|value| -value))?;
4011        for index in 0..4 {
4012            corrected[index] += step[index];
4013        }
4014        clamp_intersection_parameters(&mut corrected, space);
4015    }
4016    None
4017}
4018
4019#[derive(Clone, Copy)]
4020struct IntersectionParameterSpace {
4021    domains: [Option<([f64; 2], [f64; 2])>; 2],
4022    periods: [[Option<f64>; 2]; 2],
4023}
4024
4025fn intersection_parameter_tangent(
4026    ir: &CadIr,
4027    surfaces: [&SurfaceId; 2],
4028    parameters: [f64; 4],
4029    space: IntersectionParameterSpace,
4030    chord: Vector3,
4031) -> Option<[f64; 4]> {
4032    let jacobian = intersection_parameter_jacobian(ir, surfaces, parameters, space)?;
4033    if let Some(tangent) = null_vector_3x4(jacobian) {
4034        return Some(tangent);
4035    }
4036    let chord = unit_vector(chord)?;
4037    let derivatives = [
4038        [
4039            Vector3::new(jacobian[0][0], jacobian[1][0], jacobian[2][0]),
4040            Vector3::new(jacobian[0][1], jacobian[1][1], jacobian[2][1]),
4041        ],
4042        [
4043            Vector3::new(-jacobian[0][2], -jacobian[1][2], -jacobian[2][2]),
4044            Vector3::new(-jacobian[0][3], -jacobian[1][3], -jacobian[2][3]),
4045        ],
4046    ];
4047    let mut tangent = [0.0; 4];
4048    for side in 0..2 {
4049        let (u, v) = least_squares_step(derivatives[side][0], derivatives[side][1], chord)?;
4050        let mapped = unit_vector(Vector3::new(
4051            derivatives[side][0].x * u + derivatives[side][1].x * v,
4052            derivatives[side][0].y * u + derivatives[side][1].y * v,
4053            derivatives[side][0].z * u + derivatives[side][1].z * v,
4054        ))?;
4055        if dot_vector(mapped, chord) < 1.0 - 1.0e-8 {
4056            return None;
4057        }
4058        tangent[side * 2] = u;
4059        tangent[side * 2 + 1] = v;
4060    }
4061    let norm = tangent
4062        .iter()
4063        .map(|value| value * value)
4064        .sum::<f64>()
4065        .sqrt();
4066    (norm.is_finite() && norm > 1.0e-14).then(|| tangent.map(|value| value / norm))
4067}
4068
4069fn intersection_parameter_jacobian(
4070    ir: &CadIr,
4071    surfaces: [&SurfaceId; 2],
4072    parameters: [f64; 4],
4073    space: IntersectionParameterSpace,
4074) -> Option<[[f64; 4]; 3]> {
4075    let pairs = [
4076        Point2::new(parameters[0], parameters[1]),
4077        Point2::new(parameters[2], parameters[3]),
4078    ];
4079    let derivatives = std::array::from_fn(|side| {
4080        let u_step =
4081            parameter_derivative_step(pairs[side].u, space.domains[side].map(|value| value.0));
4082        let v_step =
4083            parameter_derivative_step(pairs[side].v, space.domains[side].map(|value| value.1));
4084        Some([
4085            model_surface_derivative(
4086                ir,
4087                surfaces[side],
4088                pairs[side],
4089                u_step,
4090                true,
4091                space.domains[side],
4092                space.periods[side],
4093            )?,
4094            model_surface_derivative(
4095                ir,
4096                surfaces[side],
4097                pairs[side],
4098                v_step,
4099                false,
4100                space.domains[side],
4101                space.periods[side],
4102            )?,
4103        ])
4104    });
4105    let [Some(first), Some(second)] = derivatives else {
4106        return None;
4107    };
4108    Some([
4109        [first[0].x, first[1].x, -second[0].x, -second[1].x],
4110        [first[0].y, first[1].y, -second[0].y, -second[1].y],
4111        [first[0].z, first[1].z, -second[0].z, -second[1].z],
4112    ])
4113}
4114
4115fn clamp_intersection_parameters(parameters: &mut [f64; 4], space: IntersectionParameterSpace) {
4116    for side in 0..2 {
4117        let mut pair = Point2::new(parameters[side * 2], parameters[side * 2 + 1]);
4118        clamp_surface_parameters_with_periods(&mut pair, space.domains[side], space.periods[side]);
4119        parameters[side * 2] = pair.u;
4120        parameters[side * 2 + 1] = pair.v;
4121    }
4122}
4123
4124fn clamp_surface_parameters_with_periods(
4125    parameters: &mut Point2,
4126    domain: Option<([f64; 2], [f64; 2])>,
4127    periods: [Option<f64>; 2],
4128) {
4129    if let Some((u_domain, v_domain)) = domain {
4130        if periods[0].is_none() {
4131            parameters.u = parameters.u.clamp(u_domain[0], u_domain[1]);
4132        }
4133        if periods[1].is_none() {
4134            parameters.v = parameters.v.clamp(v_domain[0], v_domain[1]);
4135        }
4136    }
4137}
4138
4139fn determinant_3x3(matrix: [[f64; 3]; 3]) -> f64 {
4140    matrix[0][0] * (matrix[1][1] * matrix[2][2] - matrix[1][2] * matrix[2][1])
4141        - matrix[0][1] * (matrix[1][0] * matrix[2][2] - matrix[1][2] * matrix[2][0])
4142        + matrix[0][2] * (matrix[1][0] * matrix[2][1] - matrix[1][1] * matrix[2][0])
4143}
4144
4145fn null_vector_3x4(matrix: [[f64; 4]; 3]) -> Option<[f64; 4]> {
4146    let mut vector = [0.0; 4];
4147    for (omitted, component) in vector.iter_mut().enumerate() {
4148        let minor = std::array::from_fn(|row| {
4149            let mut column = 0;
4150            std::array::from_fn(|_| {
4151                while column == omitted {
4152                    column += 1;
4153                }
4154                let value = matrix[row][column];
4155                column += 1;
4156                value
4157            })
4158        });
4159        *component = if omitted % 2 == 0 { 1.0 } else { -1.0 } * determinant_3x3(minor);
4160    }
4161    let norm = vector.iter().map(|value| value * value).sum::<f64>().sqrt();
4162    (norm.is_finite() && norm > 1.0e-14).then(|| vector.map(|value| value / norm))
4163}
4164
4165fn solve_4x4(mut matrix: [[f64; 4]; 4], mut rhs: [f64; 4]) -> Option<[f64; 4]> {
4166    for pivot in 0..4 {
4167        let row = (pivot..4).max_by(|first, second| {
4168            matrix[*first][pivot]
4169                .abs()
4170                .total_cmp(&matrix[*second][pivot].abs())
4171        })?;
4172        if !matrix[row][pivot].is_finite() || matrix[row][pivot].abs() <= 1.0e-14 {
4173            return None;
4174        }
4175        matrix.swap(pivot, row);
4176        rhs.swap(pivot, row);
4177        let pivot_row = matrix[pivot];
4178        for row in pivot + 1..4 {
4179            let factor = matrix[row][pivot] / matrix[pivot][pivot];
4180            for (value, pivot_value) in matrix[row][pivot..].iter_mut().zip(&pivot_row[pivot..]) {
4181                *value -= factor * pivot_value;
4182            }
4183            rhs[row] -= factor * rhs[pivot];
4184        }
4185    }
4186    let mut solution = [0.0; 4];
4187    for row in (0..4).rev() {
4188        let known = (row + 1..4)
4189            .map(|column| matrix[row][column] * solution[column])
4190            .sum::<f64>();
4191        solution[row] = (rhs[row] - known) / matrix[row][row];
4192    }
4193    solution
4194        .iter()
4195        .all(|value| value.is_finite())
4196        .then_some(solution)
4197}
4198
4199fn least_squares_step(du: Vector3, dv: Vector3, residual: Vector3) -> Option<(f64, f64)> {
4200    let dot =
4201        |left: Vector3, right: Vector3| left.x * right.x + left.y * right.y + left.z * right.z;
4202    let du_squared = dot(du, du);
4203    let mixed = dot(du, dv);
4204    let dv_squared = dot(dv, dv);
4205    let determinant = du_squared * dv_squared - mixed * mixed;
4206    if !determinant.is_finite()
4207        || determinant.abs() <= f64::EPSILON * du_squared.max(dv_squared).powi(2)
4208    {
4209        return None;
4210    }
4211    let du_residual = dot(du, residual);
4212    let dv_residual = dot(dv, residual);
4213    Some((
4214        (dv_squared * du_residual - mixed * dv_residual) / determinant,
4215        (du_squared * dv_residual - mixed * du_residual) / determinant,
4216    ))
4217}
4218
4219pub(crate) fn nurbs_parameters(
4220    surface: &NurbsSurface,
4221    point: Point3,
4222    seed: Option<Point2>,
4223) -> Option<Point2> {
4224    let seed = seed.filter(|seed| seed.u.is_finite() && seed.v.is_finite());
4225    let u_degree = usize::try_from(surface.u_degree).ok()?;
4226    let v_degree = usize::try_from(surface.v_degree).ok()?;
4227    let u_count = usize::try_from(surface.u_count).ok()?;
4228    let v_count = usize::try_from(surface.v_count).ok()?;
4229    let u_domain = [
4230        *surface.u_knots.get(u_degree)?,
4231        *surface.u_knots.get(u_count)?,
4232    ];
4233    let v_domain = [
4234        *surface.v_knots.get(v_degree)?,
4235        *surface.v_knots.get(v_count)?,
4236    ];
4237    if u_domain[0] >= u_domain[1] || v_domain[0] >= v_domain[1] {
4238        return None;
4239    }
4240    let squared_distance = |candidate: Point3| point_distance(candidate, point).powi(2);
4241    let mut coarse = vec![None; 81];
4242    for ui in 0..=8 {
4243        for vi in 0..=8 {
4244            let ui_value = f64::from(u32::try_from(ui).ok()?);
4245            let vi_value = f64::from(u32::try_from(vi).ok()?);
4246            let parameters = Point2::new(
4247                u_domain[0] + (u_domain[1] - u_domain[0]) * ui_value / 8.0,
4248                v_domain[0] + (v_domain[1] - v_domain[0]) * vi_value / 8.0,
4249            );
4250            let Some(position) =
4251                cadmpeg_ir::eval::nurbs_surface_point(surface, parameters.u, parameters.v)
4252            else {
4253                continue;
4254            };
4255            coarse[ui * 9 + vi] = Some((parameters, squared_distance(position)));
4256        }
4257    }
4258    let mut starts = Vec::new();
4259    if let Some(seed) = seed {
4260        starts.push(seed);
4261    }
4262    for ui in 0..=8 {
4263        for vi in 0..=8 {
4264            let index = ui * 9 + vi;
4265            let Some((parameters, distance)) = coarse[index] else {
4266                continue;
4267            };
4268            let local_minimum = ui.saturating_sub(1)..=(ui + 1).min(8);
4269            if local_minimum
4270                .flat_map(|neighbor_u| {
4271                    (vi.saturating_sub(1)..=(vi + 1).min(8))
4272                        .map(move |neighbor_v| neighbor_u * 9 + neighbor_v)
4273                })
4274                .all(|neighbor| coarse[neighbor].is_none_or(|(_, value)| distance <= value))
4275            {
4276                starts.push(parameters);
4277            }
4278        }
4279    }
4280    let mut best = None;
4281    let mut best_distance = f64::INFINITY;
4282    let mut best_seed_distance = f64::INFINITY;
4283    for start in starts {
4284        let Some(parameters) = refine_nurbs_surface_parameters(
4285            surface,
4286            point,
4287            start,
4288            u_domain,
4289            v_domain,
4290            &squared_distance,
4291        ) else {
4292            continue;
4293        };
4294        let Some(position) =
4295            cadmpeg_ir::eval::nurbs_surface_point(surface, parameters.u, parameters.v)
4296        else {
4297            continue;
4298        };
4299        let distance = squared_distance(position);
4300        let seed_distance = seed.map_or(parameters.u.abs() + parameters.v.abs(), |seed| {
4301            (parameters.u - seed.u).hypot(parameters.v - seed.v)
4302        });
4303        let same_point = (distance - best_distance).abs()
4304            <= f64::EPSILON * 64.0 * distance.abs().max(best_distance.abs()).max(1.0);
4305        if distance < best_distance && !same_point
4306            || same_point && seed_distance < best_seed_distance
4307        {
4308            best = Some(parameters);
4309            best_distance = distance;
4310            best_seed_distance = seed_distance;
4311        }
4312    }
4313    best
4314}
4315
4316fn refine_nurbs_surface_parameters(
4317    surface: &NurbsSurface,
4318    point: Point3,
4319    mut parameters: Point2,
4320    u_domain: [f64; 2],
4321    v_domain: [f64; 2],
4322    squared_distance: &impl Fn(Point3) -> f64,
4323) -> Option<Point2> {
4324    parameters.u = parameters.u.clamp(u_domain[0], u_domain[1]);
4325    parameters.v = parameters.v.clamp(v_domain[0], v_domain[1]);
4326    for _ in 0..32 {
4327        let position = cadmpeg_ir::eval::nurbs_surface_point(surface, parameters.u, parameters.v)?;
4328        let residual = Vector3::new(
4329            position.x - point.x,
4330            position.y - point.y,
4331            position.z - point.z,
4332        );
4333        let partials = nurbs_surface_partials(surface, parameters.u, parameters.v)?;
4334        let (du, dv) = (partials.du, partials.dv);
4335        let dot =
4336            |left: Vector3, right: Vector3| left.x * right.x + left.y * right.y + left.z * right.z;
4337        let du_squared = dot(du, du);
4338        let mixed = dot(du, dv);
4339        let dv_squared = dot(dv, dv);
4340        let determinant = du_squared * dv_squared - mixed * mixed;
4341        if !determinant.is_finite()
4342            || determinant.abs() <= f64::EPSILON * du_squared.max(dv_squared).powi(2)
4343        {
4344            break;
4345        }
4346        let du_residual = dot(du, residual);
4347        let dv_residual = dot(dv, residual);
4348        let step = Point2::new(
4349            (dv_squared * du_residual - mixed * dv_residual) / determinant,
4350            (du_squared * dv_residual - mixed * du_residual) / determinant,
4351        );
4352        let current_distance = squared_distance(position);
4353        let mut scale = 1.0;
4354        let mut accepted = None;
4355        for _ in 0..16 {
4356            let candidate = Point2::new(
4357                (parameters.u - scale * step.u).clamp(u_domain[0], u_domain[1]),
4358                (parameters.v - scale * step.v).clamp(v_domain[0], v_domain[1]),
4359            );
4360            let candidate_position =
4361                cadmpeg_ir::eval::nurbs_surface_point(surface, candidate.u, candidate.v)?;
4362            if squared_distance(candidate_position) <= current_distance {
4363                accepted = Some(candidate);
4364                break;
4365            }
4366            scale *= 0.5;
4367        }
4368        let Some(candidate) = accepted else {
4369            break;
4370        };
4371        parameters = candidate;
4372        if scale * step.u.abs() <= 1.0e-12 * (1.0 + parameters.u.abs())
4373            && scale * step.v.abs() <= 1.0e-12 * (1.0 + parameters.v.abs())
4374        {
4375            break;
4376        }
4377    }
4378    Some(parameters)
4379}
4380
4381fn point_distance(first: Point3, second: Point3) -> f64 {
4382    ((first.x - second.x).powi(2) + (first.y - second.y).powi(2) + (first.z - second.z).powi(2))
4383        .sqrt()
4384}
4385
4386fn intersection_side(
4387    ir: &CadIr,
4388    surfaces_by_xmt: &BTreeMap<u32, SurfaceId>,
4389    surface_xmt: u32,
4390    uv: Option<(&[[f64; 2]], &[f64])>,
4391) -> IntcurveSupportSide {
4392    let surface = surfaces_by_xmt.get(&surface_xmt).cloned();
4393    let pcurve = surface.as_ref().and_then(|surface_id| {
4394        let geometry = ir
4395            .model
4396            .surfaces
4397            .iter()
4398            .find(|candidate| &candidate.id == surface_id)
4399            .map(|surface| &surface.geometry)?;
4400        let (uv, parameters) = uv?;
4401        let control_points = uv
4402            .iter()
4403            .map(|pair| surface_parameters(geometry, *pair))
4404            .collect::<Option<Vec<_>>>()?;
4405        Some(PcurveGeometry::Nurbs {
4406            degree: 1,
4407            knots: linear_knots(parameters),
4408            control_points,
4409            weights: None,
4410            periodic: false,
4411        })
4412    });
4413    IntcurveSupportSide {
4414        surface,
4415        pcurve,
4416        pcurve_parameter_range: None,
4417    }
4418}
4419
4420fn surface_parameters(surface: &SurfaceGeometry, uv: [f64; 2]) -> Option<Point2> {
4421    let point = match surface {
4422        SurfaceGeometry::Plane { .. } => Point2::new(uv[0] * 1000.0, uv[1] * 1000.0),
4423        SurfaceGeometry::Cylinder { .. } | SurfaceGeometry::Cone { .. } => {
4424            Point2::new(uv[0], uv[1] * 1000.0)
4425        }
4426        SurfaceGeometry::Sphere { .. }
4427        | SurfaceGeometry::Torus { .. }
4428        | SurfaceGeometry::Nurbs(_)
4429        | SurfaceGeometry::Polygonal { .. }
4430        | SurfaceGeometry::Procedural { .. }
4431        | SurfaceGeometry::Unknown { .. } => Point2::new(uv[0], uv[1]),
4432        SurfaceGeometry::Transformed { basis, .. } => return surface_parameters(basis, uv),
4433    };
4434    [point.u, point.v]
4435        .into_iter()
4436        .all(f64::is_finite)
4437        .then_some(point)
4438}
4439
4440fn normalize_pcurve_parameters(
4441    pcurve: &mut PcurveGeometry,
4442    surface: &SurfaceGeometry,
4443) -> Option<()> {
4444    match pcurve {
4445        PcurveGeometry::Line { origin, direction } => {
4446            let end = Point2::new(origin.u + direction.u, origin.v + direction.v);
4447            let converted_origin = surface_parameters(surface, [origin.u, origin.v])?;
4448            let converted_end = surface_parameters(surface, [end.u, end.v])?;
4449            *origin = converted_origin;
4450            *direction = Point2::new(
4451                converted_end.u - converted_origin.u,
4452                converted_end.v - converted_origin.v,
4453            );
4454        }
4455        PcurveGeometry::Nurbs { control_points, .. } => {
4456            let converted = control_points
4457                .iter()
4458                .map(|point| surface_parameters(surface, [point.u, point.v]))
4459                .collect::<Option<Vec<_>>>()?;
4460            *control_points = converted;
4461        }
4462        _ => {}
4463    }
4464    Some(())
4465}
4466
4467// The parameters are the per-stream lookup tables produced by the decode pass;
4468// bundling them into a struct would only rename the same lookup tables.
4469#[allow(clippy::too_many_arguments)]
4470fn emit_topology(
4471    ir: &mut CadIr,
4472    stream_index: usize,
4473    graph: &Graph,
4474    points: &BTreeMap<u32, PointId>,
4475    surfaces: &BTreeMap<u32, SurfaceId>,
4476    curves: &BTreeMap<u32, CurveId>,
4477    pcurves: &BTreeMap<u32, PcurveId>,
4478    pcurve_supports: &BTreeMap<u32, SurfaceId>,
4479    trim_ranges: &BTreeMap<u32, [f64; 2]>,
4480    source_stream: cadmpeg_ir::annotations::StreamHandle,
4481    annotations: &mut AnnotationBuilder,
4482) {
4483    let prefix = format!("nx:s{stream_index}");
4484    let body_shape_shells = graph.body_shape_shells();
4485    let valid_face_xmts: BTreeSet<u32> = body_shape_shells
4486        .iter()
4487        .filter_map(|shell| graph.shell_face_xmts(shell))
4488        .flatten()
4489        .collect();
4490    let valid_loop_rings: BTreeMap<u32, Vec<u32>> = valid_face_xmts
4491        .iter()
4492        .filter_map(|face_xmt| graph.face_loop_rings(*face_xmt))
4493        .flatten()
4494        .collect();
4495    let valid_fin_xmts: BTreeSet<u32> = valid_loop_rings
4496        .values()
4497        .flat_map(|ring| ring.iter().copied())
4498        .collect();
4499    let valid_edge_xmts: BTreeSet<u32> = valid_fin_xmts
4500        .iter()
4501        .filter_map(|xmt| graph.get(17, *xmt)?.fin_fields().map(|fields| fields.edge))
4502        .collect();
4503    let valid_vertex_xmts: BTreeSet<u32> = valid_fin_xmts
4504        .iter()
4505        .flat_map(|xmt| {
4506            let fields = graph.get(17, *xmt).and_then(Node::fin_fields);
4507            let partner_vertex = fields
4508                .filter(|fields| fields.other > 1)
4509                .and_then(|fields| graph.get(17, fields.other))
4510                .and_then(Node::fin_fields)
4511                .map(|fields| fields.vertex);
4512            [fields.map(|fields| fields.vertex), partner_vertex]
4513                .into_iter()
4514                .flatten()
4515        })
4516        .filter(|xmt| *xmt > 1)
4517        .collect();
4518    let body_xmts: BTreeSet<_> = body_shape_shells
4519        .iter()
4520        .filter_map(|shell| shell.shell_fields().map(|fields| fields.body))
4521        .collect();
4522    let mut bodies = BTreeMap::new();
4523    for body_xmt in body_xmts {
4524        let id = BodyId(format!("{prefix}:body#{body_xmt}"));
4525        if let Some(node) = graph.get(12, body_xmt) {
4526            annotate_node(annotations, &id, source_stream, node, "BODY");
4527        } else if let Some(shell) = body_shape_shells.iter().find(|shell| {
4528            shell
4529                .shell_fields()
4530                .is_some_and(|fields| fields.body == body_xmt)
4531        }) {
4532            annotations
4533                .note(&id, source_stream, shell.pos as u64)
4534                .tag("UNRESOLVED_BODY_REFERENCE");
4535            annotations.exactness(&id, Exactness::Unknown);
4536        }
4537        bodies.insert(body_xmt, id.clone());
4538        ir.model.bodies.push(Body {
4539            id,
4540            kind: cadmpeg_ir::topology::BodyKind::Solid,
4541            regions: Vec::new(),
4542            transform: None,
4543            name: None,
4544            color: None,
4545            visible: None,
4546        });
4547    }
4548
4549    let mut regions: BTreeMap<u32, (RegionId, BodyId)> = BTreeMap::new();
4550    let mut shells = BTreeMap::new();
4551    for node in body_shape_shells {
4552        let Some(fields) = node.shell_fields() else {
4553            continue;
4554        };
4555        let Some(body) = bodies.get(&fields.body).cloned() else {
4556            continue;
4557        };
4558        let region_id = if let Some((region, owner)) = regions.get(&fields.region) {
4559            if owner != &body {
4560                continue;
4561            }
4562            region.clone()
4563        } else {
4564            let region = RegionId(format!("{prefix}:region#{}", fields.region));
4565            if let Some(region_node) = graph.get(19, fields.region) {
4566                annotate_node(annotations, &region, source_stream, region_node, "REGION");
4567            } else {
4568                annotations
4569                    .note(&region, source_stream, node.pos as u64)
4570                    .tag("UNRESOLVED_REGION_REFERENCE");
4571                annotations.exactness(&region, Exactness::Unknown);
4572            }
4573            annotations.derived(&region, "body");
4574            ir.model.regions.push(Region {
4575                id: region.clone(),
4576                body: body.clone(),
4577                shells: Vec::new(),
4578            });
4579            if let Some(parent) = ir
4580                .model
4581                .bodies
4582                .iter_mut()
4583                .find(|candidate| candidate.id == body)
4584            {
4585                parent.regions.push(region.clone());
4586            }
4587            regions.insert(fields.region, (region.clone(), body.clone()));
4588            region
4589        };
4590        let shell_id = ShellId(format!("{prefix}:shell#{}", node.xmt));
4591        annotate_node(annotations, &shell_id, source_stream, node, "SHELL");
4592        ir.model.shells.push(Shell {
4593            id: shell_id.clone(),
4594            region: region_id.clone(),
4595            faces: Vec::new(),
4596            wire_edges: Vec::new(),
4597            free_vertices: Vec::new(),
4598        });
4599        if let Some(parent) = ir
4600            .model
4601            .regions
4602            .iter_mut()
4603            .find(|candidate| candidate.id == region_id)
4604        {
4605            parent.shells.push(shell_id.clone());
4606        }
4607        shells.insert(node.xmt, shell_id);
4608    }
4609
4610    let mut vertices = BTreeMap::new();
4611    for node in graph
4612        .of_kind(18)
4613        .filter(|node| valid_vertex_xmts.contains(&node.xmt))
4614    {
4615        let Some(fields) = node.vertex_fields() else {
4616            continue;
4617        };
4618        let Some(point) = points.get(&fields.point).cloned() else {
4619            continue;
4620        };
4621        let tolerance = decoded_tolerance(fields.tolerance);
4622        let vertex = VertexId(format!("{prefix}:vertex#{}", node.xmt));
4623        annotate_node(annotations, &vertex, source_stream, node, "VERTEX");
4624        if tolerance.is_some() {
4625            annotations.derived(&vertex, "tolerance");
4626        }
4627        ir.model.vertices.push(Vertex {
4628            id: vertex.clone(),
4629            point,
4630            tolerance,
4631        });
4632        vertices.insert(node.xmt, vertex.clone());
4633    }
4634
4635    let mut edges = BTreeMap::new();
4636    for node in graph
4637        .of_kind(16)
4638        .filter(|node| valid_edge_xmts.contains(&node.xmt))
4639    {
4640        let Some(fields) = node.edge_fields() else {
4641            continue;
4642        };
4643        let Some(fin) = graph.get(17, fields.fin) else {
4644            continue;
4645        };
4646        let Some(fin_fields) = fin.fin_fields() else {
4647            continue;
4648        };
4649        let curve_xmt = [fields.curve, fin_fields.curve_xmt]
4650            .into_iter()
4651            .find(|xmt| *xmt > 1);
4652        let mut curve = curve_xmt.and_then(|xmt| curves.get(&xmt)).cloned();
4653        let mut param_range = curve_xmt.and_then(|xmt| trim_ranges.get(&xmt)).copied();
4654        if curve.is_none() {
4655            let lifted = curve_xmt
4656                .and_then(|xmt| pcurves.get(&xmt))
4657                .and_then(|pcurve_id| {
4658                    let pcurve = ir
4659                        .model
4660                        .pcurves
4661                        .iter()
4662                        .find(|pcurve| &pcurve.id == pcurve_id)?;
4663                    let surface = pcurve_supports.get(&curve_xmt?)?.clone();
4664                    let parameter_range = pcurve
4665                        .parameter_range
4666                        .or(param_range)
4667                        .or_else(|| pcurve_parameter_range(&pcurve.geometry))?;
4668                    let parameter_range = ordered_parameter_range(parameter_range)?;
4669                    Some((
4670                        surface,
4671                        pcurve.geometry.clone(),
4672                        parameter_range,
4673                        pcurve.fit_tolerance,
4674                    ))
4675                });
4676            if let Some((surface, pcurve, parameter_range, _fit_tolerance)) = lifted {
4677                let carrier = CurveId(format!("{prefix}:edge-parametric-curve#{}", node.xmt));
4678                let construction = ProceduralCurveId(format!(
4679                    "{prefix}:edge-parametric-construction#{}",
4680                    node.xmt
4681                ));
4682                annotations
4683                    .note(&carrier, source_stream, node.pos as u64)
4684                    .tag("PARAMETRIC_SURFACE_CURVE");
4685                annotations.derived(&carrier, "geometry");
4686                ir.model.curves.push(Curve {
4687                    id: carrier.clone(),
4688                    geometry: CurveGeometry::Procedural {
4689                        construction: construction.clone(),
4690                    },
4691                    source_object: None,
4692                });
4693                ir.model.procedural_curves.push(ProceduralCurve {
4694                    id: construction,
4695                    curve: carrier.clone(),
4696                    definition: ProceduralCurveDefinition::SurfaceCurve {
4697                        family: SurfaceCurveFamily::Parametric,
4698                        context: IntcurveSupportContext {
4699                            sides: [
4700                                IntcurveSupportSide {
4701                                    surface: Some(surface),
4702                                    pcurve: Some(pcurve),
4703                                    pcurve_parameter_range: None,
4704                                },
4705                                IntcurveSupportSide {
4706                                    surface: None,
4707                                    pcurve: None,
4708                                    pcurve_parameter_range: None,
4709                                },
4710                            ],
4711                            parameter_range,
4712                            discontinuities: [Vec::new(), Vec::new(), Vec::new()],
4713                        },
4714                        tail: None,
4715                    },
4716                    // The pcurve carries this fit contract; this construction has no
4717                    // independent solved 3D cache to qualify.
4718                    cache_fit_tolerance: None,
4719                });
4720                curve = Some(carrier);
4721                param_range = None;
4722            }
4723        }
4724        let start = vertices.get(&fin_fields.vertex).cloned().or_else(|| {
4725            (fin_fields.vertex == 1
4726                && fin_fields.forward == fin.xmt
4727                && fin_fields.backward == fin.xmt)
4728                .then(|| {
4729                    synthesize_closed_edge_vertex(
4730                        ir,
4731                        annotations,
4732                        &prefix,
4733                        node,
4734                        curve.as_ref()?,
4735                        param_range,
4736                        source_stream,
4737                        decoded_tolerance(fields.tolerance),
4738                    )
4739                })
4740                .flatten()
4741        });
4742        let Some(start) = start else {
4743            continue;
4744        };
4745        let end_fin = if fin_fields.other > 1 {
4746            fin_fields.other
4747        } else {
4748            fin_fields.forward
4749        };
4750        let end = graph
4751            .get(17, end_fin)
4752            .and_then(Node::fin_fields)
4753            .and_then(|next| vertices.get(&next.vertex))
4754            .cloned()
4755            .unwrap_or_else(|| start.clone());
4756        let (mut start, mut end) = (start, end);
4757        let id = EdgeId(format!("{prefix}:edge#{}", node.xmt));
4758        annotate_node(annotations, &id, source_stream, node, "EDGE");
4759        if decoded_tolerance(fields.tolerance).is_some() {
4760            annotations.derived(&id, "tolerance");
4761        }
4762        if let (Some(carrier), Some(range)) = (&curve, param_range) {
4763            match orient_edge_range(
4764                ir,
4765                carrier,
4766                range,
4767                &start,
4768                &end,
4769                decoded_tolerance(fields.tolerance),
4770            ) {
4771                Some((oriented, reverse_edge)) => {
4772                    param_range = Some(oriented);
4773                    if reverse_edge {
4774                        std::mem::swap(&mut start, &mut end);
4775                    }
4776                }
4777                None => {
4778                    param_range = None;
4779                }
4780            }
4781        }
4782        ir.model.edges.push(Edge {
4783            id: id.clone(),
4784            curve,
4785            start,
4786            end,
4787            param_range,
4788            tolerance: decoded_tolerance(fields.tolerance),
4789        });
4790        edges.insert(node.xmt, id);
4791    }
4792
4793    let mut faces = BTreeMap::new();
4794    for node in graph
4795        .of_kind(14)
4796        .filter(|node| valid_face_xmts.contains(&node.xmt))
4797    {
4798        let Some(fields) = node.face_fields() else {
4799            continue;
4800        };
4801        let Some(shell) = shells.get(&fields.shell).cloned() else {
4802            continue;
4803        };
4804        let Some(surface) = surfaces.get(&fields.surface).cloned() else {
4805            continue;
4806        };
4807        let id = FaceId(format!("{prefix}:face#{}", node.xmt));
4808        annotate_node(annotations, &id, source_stream, node, "FACE");
4809        if decoded_tolerance(fields.tolerance).is_some() {
4810            annotations.derived(&id, "tolerance");
4811        }
4812        ir.model.faces.push(Face {
4813            id: id.clone(),
4814            shell: shell.clone(),
4815            surface,
4816            sense: sense(Some(fields.sense)),
4817            loops: Vec::new(),
4818            name: None,
4819            color: None,
4820            tolerance: decoded_tolerance(fields.tolerance),
4821        });
4822        if let Some(parent) = ir
4823            .model
4824            .shells
4825            .iter_mut()
4826            .find(|candidate| candidate.id == shell)
4827        {
4828            parent.faces.push(id.clone());
4829        }
4830        faces.insert(node.xmt, id);
4831    }
4832
4833    let mut loops = BTreeMap::new();
4834    for &loop_xmt in valid_loop_rings.keys() {
4835        let ring_resolves = valid_loop_rings[&loop_xmt].iter().all(|fin_xmt| {
4836            graph
4837                .get(17, *fin_xmt)
4838                .and_then(Node::fin_fields)
4839                .is_some_and(|fields| edges.contains_key(&fields.edge))
4840        });
4841        if !ring_resolves {
4842            continue;
4843        }
4844        let Some(node) = graph.get(15, loop_xmt) else {
4845            continue;
4846        };
4847        let Some(fields) = node.loop_fields() else {
4848            continue;
4849        };
4850        let Some(face) = faces.get(&fields.face).cloned() else {
4851            continue;
4852        };
4853        let id = LoopId(format!("{prefix}:loop#{}", node.xmt));
4854        annotate_node(annotations, &id, source_stream, node, "LOOP");
4855        ir.model.loops.push(Loop {
4856            id: id.clone(),
4857            face: face.clone(),
4858            boundary_role: cadmpeg_ir::topology::LoopBoundaryRole::Unspecified,
4859            coedges: Vec::new(),
4860            vertex_uses: Vec::new(),
4861        });
4862        if let Some(parent) = ir
4863            .model
4864            .faces
4865            .iter_mut()
4866            .find(|candidate| candidate.id == face)
4867        {
4868            parent.loops.push(id.clone());
4869        }
4870        loops.insert(node.xmt, id);
4871    }
4872
4873    let fin_ids: BTreeMap<u32, CoedgeId> = valid_fin_xmts
4874        .iter()
4875        .filter(|xmt| {
4876            graph
4877                .get(17, **xmt)
4878                .and_then(Node::fin_fields)
4879                .is_some_and(|fields| loops.contains_key(&fields.loop_xmt))
4880        })
4881        .map(|xmt| (*xmt, CoedgeId(format!("{prefix}:fin#{xmt}"))))
4882        .collect();
4883    let intersection_pcurves: BTreeMap<_, _> = ir
4884        .model
4885        .procedural_curves
4886        .iter()
4887        .filter_map(|procedural| {
4888            let ProceduralCurveDefinition::Intersection { context, .. } = &procedural.definition
4889            else {
4890                return None;
4891            };
4892            Some(context.sides.iter().filter_map(move |side| {
4893                Some((
4894                    (procedural.curve.clone(), side.surface.clone()?),
4895                    (
4896                        side.pcurve.clone()?,
4897                        context.parameter_range,
4898                        procedural.cache_fit_tolerance,
4899                    ),
4900                ))
4901            }))
4902        })
4903        .flatten()
4904        .collect();
4905    for &fin_xmt in fin_ids.keys() {
4906        let Some(node) = graph.get(17, fin_xmt) else {
4907            continue;
4908        };
4909        let Some(fields) = node.fin_fields() else {
4910            continue;
4911        };
4912        let Some(loop_id) = loops.get(&fields.loop_xmt).cloned() else {
4913            continue;
4914        };
4915        let Some(edge) = edges.get(&fields.edge).cloned() else {
4916            continue;
4917        };
4918        let id = fin_ids.get(&node.xmt).cloned().expect("filtered above");
4919        annotate_node(annotations, &id, source_stream, node, "FIN");
4920        let next = fin_ids
4921            .get(&fields.forward)
4922            .cloned()
4923            .expect("validated FIN ring resolves forward link");
4924        let previous = fin_ids
4925            .get(&fields.backward)
4926            .cloned()
4927            .expect("validated FIN ring resolves backward link");
4928        let partner = fin_ids.get(&fields.other).cloned();
4929        let radial_next = partner.clone().unwrap_or_else(|| id.clone());
4930        let support = graph
4931            .get(15, fields.loop_xmt)
4932            .and_then(Node::loop_fields)
4933            .and_then(|loop_| graph.get(14, loop_.face))
4934            .and_then(Node::face_fields)
4935            .and_then(|face| surfaces.get(&face.surface))
4936            .cloned();
4937        let mut pcurve = pcurves.get(&fields.curve_xmt).cloned().filter(|id| {
4938            let Some((carrier, support)) = ir
4939                .model
4940                .pcurves
4941                .iter()
4942                .find(|carrier| &carrier.id == id)
4943                .zip(support.as_ref())
4944            else {
4945                return false;
4946            };
4947            pcurve_matches_edge_range(
4948                ir,
4949                &edge,
4950                support,
4951                &carrier.geometry,
4952                carrier.parameter_range,
4953                carrier.fit_tolerance,
4954            )
4955        });
4956        if pcurve.is_none() {
4957            let carrier = ir
4958                .model
4959                .edges
4960                .iter()
4961                .find(|candidate| candidate.id == edge)
4962                .and_then(|edge| edge.curve.clone());
4963            if let Some((_support, geometry, parameter_range, fit_tolerance)) = carrier
4964                .zip(support)
4965                .and_then(|key| {
4966                    intersection_pcurves
4967                        .get(&key)
4968                        .cloned()
4969                        .map(|value| (key.1, value.0, value.1, value.2))
4970                })
4971                .filter(|(support, geometry, _, fit_tolerance)| {
4972                    pcurve_matches_edge(ir, &edge, support, geometry, *fit_tolerance)
4973                })
4974            {
4975                let pcurve_id = PcurveId(format!("{prefix}:intersection-pcurve#{fin_xmt}"));
4976                annotations
4977                    .note(&pcurve_id, source_stream, node.pos as u64)
4978                    .tag("INTERSECTION_PCURVE");
4979                annotations.derived(&pcurve_id, "geometry");
4980                annotations.derived(&pcurve_id, "parameter_range");
4981                if fit_tolerance.is_some() {
4982                    annotations.derived(&pcurve_id, "fit_tolerance");
4983                }
4984                ir.model.pcurves.push(Pcurve {
4985                    id: pcurve_id.clone(),
4986                    geometry,
4987                    wrapper_reversed: None,
4988                    native_tail_flags: None,
4989                    parameter_range: Some(parameter_range),
4990                    fit_tolerance,
4991                });
4992                pcurve = Some(pcurve_id);
4993            }
4994        }
4995        ir.model.coedges.push(Coedge {
4996            id: id.clone(),
4997            owner_loop: loop_id.clone(),
4998            edge,
4999            next,
5000            previous,
5001            radial_next,
5002            sense: sense(Some(fields.sense)),
5003            pcurves: pcurve
5004                .into_iter()
5005                .map(|pcurve| cadmpeg_ir::topology::PcurveUse {
5006                    pcurve,
5007                    isoparametric: None,
5008                    parameter_range: None,
5009                })
5010                .collect(),
5011            use_curve: None,
5012            use_curve_parameter_range: None,
5013        });
5014        if let Some(parent) = ir
5015            .model
5016            .loops
5017            .iter_mut()
5018            .find(|candidate| candidate.id == loop_id)
5019        {
5020            parent.coedges.push(id);
5021        }
5022    }
5023
5024    attach_tolerant_edge_intersections(ir, graph, &edges, &prefix, source_stream, annotations);
5025    complete_intersection_supports_from_edge_incidence(ir);
5026    complete_intersection_pcurves_from_coedge_incidence(ir);
5027    complete_isoparametric_intersection_pcurves(ir);
5028    complete_intersection_pcurves_from_opposite_charts(ir);
5029
5030    let owned_edges: BTreeSet<_> = ir
5031        .model
5032        .coedges
5033        .iter()
5034        .map(|coedge| coedge.edge.clone())
5035        .collect();
5036    let candidate_edges: BTreeSet<_> = edges.into_values().collect();
5037    ir.model
5038        .edges
5039        .retain(|edge| !candidate_edges.contains(&edge.id) || owned_edges.contains(&edge.id));
5040    let retained_vertices: BTreeSet<_> = ir
5041        .model
5042        .edges
5043        .iter()
5044        .flat_map(|edge| [edge.start.clone(), edge.end.clone()])
5045        .collect();
5046    ir.model.vertices.retain(|vertex| {
5047        !vertex.id.0.starts_with(&prefix) || retained_vertices.contains(&vertex.id)
5048    });
5049}
5050
5051fn pcurve_parameter_range(geometry: &PcurveGeometry) -> Option<[f64; 2]> {
5052    let PcurveGeometry::Nurbs { knots, .. } = geometry else {
5053        return None;
5054    };
5055    ordered_parameter_range([*knots.first()?, *knots.last()?])
5056}
5057
5058fn ordered_parameter_range(mut range: [f64; 2]) -> Option<[f64; 2]> {
5059    if !range.iter().all(|value| value.is_finite()) || range[0] == range[1] {
5060        return None;
5061    }
5062    if range[0] > range[1] {
5063        range.swap(0, 1);
5064    }
5065    Some(range)
5066}
5067
5068pub(crate) fn complete_intersection_supports_from_edge_incidence(ir: &mut CadIr) {
5069    let loop_faces = ir
5070        .model
5071        .loops
5072        .iter()
5073        .map(|loop_| (loop_.id.clone(), loop_.face.clone()))
5074        .collect::<BTreeMap<_, _>>();
5075    let face_surfaces = ir
5076        .model
5077        .faces
5078        .iter()
5079        .map(|face| (face.id.clone(), face.surface.clone()))
5080        .collect::<BTreeMap<_, _>>();
5081    let edge_curves = ir
5082        .model
5083        .edges
5084        .iter()
5085        .filter_map(|edge| Some((edge.id.clone(), edge.curve.clone()?)))
5086        .collect::<BTreeMap<_, _>>();
5087    let mut incident_surfaces = BTreeMap::<CurveId, Vec<SurfaceId>>::new();
5088    for coedge in &ir.model.coedges {
5089        let Some(curve) = edge_curves.get(&coedge.edge) else {
5090            continue;
5091        };
5092        let Some(surface) = loop_faces
5093            .get(&coedge.owner_loop)
5094            .and_then(|face| face_surfaces.get(face))
5095        else {
5096            continue;
5097        };
5098        let surfaces = incident_surfaces.entry(curve.clone()).or_default();
5099        if !surfaces.contains(surface) {
5100            surfaces.push(surface.clone());
5101        }
5102    }
5103
5104    for procedural in &mut ir.model.procedural_curves {
5105        let ProceduralCurveDefinition::Intersection { context, .. } = &mut procedural.definition
5106        else {
5107            continue;
5108        };
5109        let missing = context
5110            .sides
5111            .iter()
5112            .enumerate()
5113            .filter_map(|(index, side)| side.surface.is_none().then_some(index))
5114            .collect::<Vec<_>>();
5115        if missing.len() != 1 {
5116            continue;
5117        }
5118        let Some(incident) = incident_surfaces.get(&procedural.curve) else {
5119            continue;
5120        };
5121        let candidates = incident
5122            .iter()
5123            .filter(|surface| {
5124                !context
5125                    .sides
5126                    .iter()
5127                    .any(|side| side.surface.as_ref() == Some(surface))
5128            })
5129            .collect::<Vec<_>>();
5130        let [surface] = candidates.as_slice() else {
5131            continue;
5132        };
5133        context.sides[missing[0]].surface = Some((*surface).clone());
5134    }
5135}
5136
5137pub(crate) fn complete_intersection_pcurves_from_coedge_incidence(ir: &mut CadIr) {
5138    let loop_faces = ir
5139        .model
5140        .loops
5141        .iter()
5142        .map(|loop_| (loop_.id.clone(), loop_.face.clone()))
5143        .collect::<BTreeMap<_, _>>();
5144    let face_surfaces = ir
5145        .model
5146        .faces
5147        .iter()
5148        .map(|face| (face.id.clone(), face.surface.clone()))
5149        .collect::<BTreeMap<_, _>>();
5150    let edge_curves = ir
5151        .model
5152        .edges
5153        .iter()
5154        .filter_map(|edge| Some((edge.id.clone(), edge.curve.clone()?)))
5155        .collect::<BTreeMap<_, _>>();
5156    let mut incident_pcurves = BTreeMap::<(CurveId, SurfaceId), Vec<PcurveId>>::new();
5157    for coedge in &ir.model.coedges {
5158        let Some(curve) = edge_curves.get(&coedge.edge) else {
5159            continue;
5160        };
5161        let Some(surface) = loop_faces
5162            .get(&coedge.owner_loop)
5163            .and_then(|face| face_surfaces.get(face))
5164        else {
5165            continue;
5166        };
5167        let pcurves = incident_pcurves
5168            .entry((curve.clone(), surface.clone()))
5169            .or_default();
5170        for pcurve in &coedge.pcurves {
5171            if !pcurves.contains(&pcurve.pcurve) {
5172                pcurves.push(pcurve.pcurve.clone());
5173            }
5174        }
5175    }
5176
5177    for procedural in &mut ir.model.procedural_curves {
5178        let ProceduralCurveDefinition::Intersection { context, .. } = &mut procedural.definition
5179        else {
5180            continue;
5181        };
5182        for side in &mut context.sides {
5183            if side.pcurve.is_some() {
5184                continue;
5185            }
5186            let Some(surface) = &side.surface else {
5187                continue;
5188            };
5189            let Some([pcurve]) = incident_pcurves
5190                .get(&(procedural.curve.clone(), surface.clone()))
5191                .map(Vec::as_slice)
5192            else {
5193                continue;
5194            };
5195            let Some(carrier) = ir
5196                .model
5197                .pcurves
5198                .iter()
5199                .find(|carrier| &carrier.id == pcurve)
5200            else {
5201                continue;
5202            };
5203            side.pcurve = Some(carrier.geometry.clone());
5204        }
5205    }
5206}
5207
5208pub(crate) fn complete_intersection_pcurves_from_opposite_charts(ir: &mut CadIr) {
5209    let edge_tolerances = ir
5210        .model
5211        .edges
5212        .iter()
5213        .filter_map(|edge| {
5214            Some((
5215                edge.curve.clone()?,
5216                edge.tolerance
5217                    .filter(|value| value.is_finite() && *value >= 0.0)?,
5218            ))
5219        })
5220        .fold(
5221            BTreeMap::<CurveId, f64>::new(),
5222            |mut values, (curve, tolerance)| {
5223                values
5224                    .entry(curve)
5225                    .and_modify(|current| *current = current.min(tolerance))
5226                    .or_insert(tolerance);
5227                values
5228            },
5229        );
5230    let replacements = ir
5231        .model
5232        .procedural_curves
5233        .iter()
5234        .filter_map(|procedural| {
5235            let ProceduralCurveDefinition::Intersection { context, .. } = &procedural.definition
5236            else {
5237                return None;
5238            };
5239            let missing = context
5240                .sides
5241                .each_ref()
5242                .map(|side| pcurve_requires_completion(side.pcurve.as_ref()));
5243            let target = match missing {
5244                [true, false] => 0,
5245                [false, true] => 1,
5246                _ => return None,
5247            };
5248            let source = 1 - target;
5249            let source_surface = context.sides[source].surface.as_ref()?;
5250            let source_pcurve = context.sides[source].pcurve.as_ref()?;
5251            let target_surface = context.sides[target].surface.as_ref()?;
5252            let tolerance = procedural
5253                .cache_fit_tolerance
5254                .or_else(|| edge_tolerances.get(&procedural.curve).copied())?;
5255            let tolerance = blend_spine_cache_fit_tolerance(ir, target_surface, tolerance);
5256            let pcurve = transfer_intersection_pcurve(
5257                ir,
5258                &procedural.curve,
5259                source_surface,
5260                source_pcurve,
5261                target_surface,
5262                context.parameter_range,
5263                tolerance,
5264            )?;
5265            Some((procedural.id.clone(), target, pcurve, tolerance))
5266        })
5267        .collect::<Vec<_>>();
5268    for (procedural_id, side, pcurve, tolerance) in replacements {
5269        let Some(procedural) = ir
5270            .model
5271            .procedural_curves
5272            .iter_mut()
5273            .find(|procedural| procedural.id == procedural_id)
5274        else {
5275            continue;
5276        };
5277        let ProceduralCurveDefinition::Intersection { context, .. } = &mut procedural.definition
5278        else {
5279            continue;
5280        };
5281        if pcurve_requires_completion(context.sides[side].pcurve.as_ref()) {
5282            context.sides[side].pcurve = Some(pcurve);
5283            procedural.cache_fit_tolerance =
5284                Some(procedural.cache_fit_tolerance.unwrap_or(0.0).max(tolerance));
5285        }
5286    }
5287}
5288
5289pub(crate) fn complete_isoparametric_intersection_pcurves(ir: &mut CadIr) {
5290    let vertex_points = ir
5291        .model
5292        .vertices
5293        .iter()
5294        .filter_map(|vertex| {
5295            let point = ir
5296                .model
5297                .points
5298                .iter()
5299                .find(|point| point.id == vertex.point)?;
5300            Some((vertex.id.clone(), point.position))
5301        })
5302        .collect::<BTreeMap<_, _>>();
5303    let replacements = ir
5304        .model
5305        .procedural_curves
5306        .iter()
5307        .filter_map(|procedural| {
5308            let ProceduralCurveDefinition::Intersection { context, .. } = &procedural.definition
5309            else {
5310                return None;
5311            };
5312            if !context
5313                .sides
5314                .iter()
5315                .all(|side| pcurve_requires_completion(side.pcurve.as_ref()))
5316            {
5317                return None;
5318            }
5319            let [Some(first_surface), Some(second_surface)] =
5320                context.sides.each_ref().map(|side| side.surface.as_ref())
5321            else {
5322                return None;
5323            };
5324            let edges = ir
5325                .model
5326                .edges
5327                .iter()
5328                .filter(|edge| edge.curve.as_ref() == Some(&procedural.curve))
5329                .collect::<Vec<_>>();
5330            let [edge] = edges.as_slice() else {
5331                return None;
5332            };
5333            let tolerance = edge
5334                .tolerance
5335                .filter(|value| value.is_finite() && *value >= 0.0)?;
5336            let endpoints = [
5337                *vertex_points.get(&edge.start)?,
5338                *vertex_points.get(&edge.end)?,
5339            ];
5340            let candidates = [first_surface, second_surface].map(|surface| {
5341                isoparametric_boundary_pcurve(
5342                    ir,
5343                    surface,
5344                    endpoints,
5345                    context.parameter_range,
5346                    tolerance,
5347                )
5348            });
5349            let pcurves = match candidates {
5350                [Some(first), Some(second)] => coincident_pcurve_pair(
5351                    ir,
5352                    [first_surface, second_surface],
5353                    [&first, &second],
5354                    context.parameter_range,
5355                    tolerance,
5356                )
5357                .then_some([first, second])?,
5358                [Some(first), None] => [
5359                    first.clone(),
5360                    transfer_intersection_pcurve(
5361                        ir,
5362                        &procedural.curve,
5363                        first_surface,
5364                        &first,
5365                        second_surface,
5366                        context.parameter_range,
5367                        tolerance,
5368                    )?,
5369                ],
5370                [None, Some(second)] => [
5371                    transfer_intersection_pcurve(
5372                        ir,
5373                        &procedural.curve,
5374                        second_surface,
5375                        &second,
5376                        first_surface,
5377                        context.parameter_range,
5378                        tolerance,
5379                    )?,
5380                    second,
5381                ],
5382                [None, None] => return None,
5383            };
5384            Some((procedural.id.clone(), pcurves, tolerance))
5385        })
5386        .collect::<Vec<_>>();
5387    for (procedural_id, pcurves, tolerance) in replacements {
5388        let Some(procedural) = ir
5389            .model
5390            .procedural_curves
5391            .iter_mut()
5392            .find(|procedural| procedural.id == procedural_id)
5393        else {
5394            continue;
5395        };
5396        let ProceduralCurveDefinition::Intersection { context, .. } = &mut procedural.definition
5397        else {
5398            continue;
5399        };
5400        if context
5401            .sides
5402            .iter()
5403            .all(|side| pcurve_requires_completion(side.pcurve.as_ref()))
5404        {
5405            for (side, pcurve) in context.sides.iter_mut().zip(pcurves) {
5406                side.pcurve = Some(pcurve);
5407            }
5408            procedural.cache_fit_tolerance = Some(tolerance);
5409        }
5410    }
5411}
5412
5413fn isoparametric_boundary_pcurve(
5414    ir: &CadIr,
5415    surface: &SurfaceId,
5416    endpoints: [Point3; 2],
5417    range: [f64; 2],
5418    tolerance: f64,
5419) -> Option<PcurveGeometry> {
5420    (range[0].is_finite() && range[1].is_finite() && range[0] < range[1]).then_some(())?;
5421    let carrier = ir
5422        .model
5423        .surfaces
5424        .iter()
5425        .find(|candidate| &candidate.id == surface)?;
5426    let SurfaceGeometry::Nurbs(nurbs) = &carrier.geometry else {
5427        return None;
5428    };
5429    let domain = surface_parameter_domain(ir, surface)?;
5430    let parameters = [
5431        nurbs_parameters(nurbs, endpoints[0], None)?,
5432        nurbs_parameters(nurbs, endpoints[1], None)?,
5433    ];
5434    for index in 0..2 {
5435        let point =
5436            cadmpeg_ir::eval::nurbs_surface_point(nurbs, parameters[index].u, parameters[index].v)?;
5437        if point_distance(point, endpoints[index]) > tolerance {
5438            return None;
5439        }
5440    }
5441    let axes = [
5442        ([parameters[0].u, parameters[1].u], domain.0),
5443        ([parameters[0].v, parameters[1].v], domain.1),
5444    ];
5445    let candidates = axes
5446        .into_iter()
5447        .enumerate()
5448        .filter_map(|(constant_axis, (values, axis_domain))| {
5449            let scale = (axis_domain[1] - axis_domain[0]).abs().max(1.0);
5450            let parameter_tolerance = 1.0e-8 * scale;
5451            let boundary = axis_domain.into_iter().find(|boundary| {
5452                values
5453                    .iter()
5454                    .all(|value| (*value - *boundary).abs() <= parameter_tolerance)
5455            })?;
5456            let varying = if constant_axis == 0 {
5457                [parameters[0].v, parameters[1].v]
5458            } else {
5459                [parameters[0].u, parameters[1].u]
5460            };
5461            ((varying[1] - varying[0]).abs() > parameter_tolerance).then(|| {
5462                let delta = (varying[1] - varying[0]) / (range[1] - range[0]);
5463                let (origin, direction) = if constant_axis == 0 {
5464                    (
5465                        Point2::new(boundary, varying[0] - delta * range[0]),
5466                        Point2::new(0.0, delta),
5467                    )
5468                } else {
5469                    (
5470                        Point2::new(varying[0] - delta * range[0], boundary),
5471                        Point2::new(delta, 0.0),
5472                    )
5473                };
5474                PcurveGeometry::Line { origin, direction }
5475            })
5476        })
5477        .collect::<Vec<_>>();
5478    let [candidate] = candidates.as_slice() else {
5479        return None;
5480    };
5481    Some(candidate.clone())
5482}
5483
5484fn coincident_pcurve_pair(
5485    ir: &CadIr,
5486    surfaces: [&SurfaceId; 2],
5487    pcurves: [&PcurveGeometry; 2],
5488    range: [f64; 2],
5489    tolerance: f64,
5490) -> bool {
5491    (0..=32).all(|index| {
5492        let fraction = f64::from(index) / 32.0;
5493        let parameter = range[0] + fraction * (range[1] - range[0]);
5494        let points = [0usize, 1usize].map(|side| {
5495            let uv = pcurve_uv(pcurves[side], parameter)?;
5496            decoded_surface_point(ir, surfaces[side], uv.u, uv.v)
5497        });
5498        matches!(points, [Some(first), Some(second)] if point_distance(first, second) <= tolerance)
5499    })
5500}
5501
5502fn transfer_intersection_pcurve(
5503    ir: &CadIr,
5504    curve: &CurveId,
5505    source_surface: &SurfaceId,
5506    source_pcurve: &PcurveGeometry,
5507    target_surface: &SurfaceId,
5508    parameter_range: [f64; 2],
5509    tolerance: f64,
5510) -> Option<PcurveGeometry> {
5511    (parameter_range[0].is_finite()
5512        && parameter_range[1].is_finite()
5513        && parameter_range[0] < parameter_range[1]
5514        && tolerance.is_finite()
5515        && tolerance >= 0.0)
5516        .then_some(())?;
5517    let first = transferred_pcurve_sample(
5518        ir,
5519        curve,
5520        source_surface,
5521        source_pcurve,
5522        target_surface,
5523        parameter_range[0],
5524        None,
5525        tolerance,
5526    )?;
5527    let last = transferred_pcurve_sample(
5528        ir,
5529        curve,
5530        source_surface,
5531        source_pcurve,
5532        target_surface,
5533        parameter_range[1],
5534        Some(first.1),
5535        tolerance,
5536    )?;
5537    let mut samples = vec![first];
5538    append_transferred_pcurve_segment(
5539        ir,
5540        curve,
5541        source_surface,
5542        source_pcurve,
5543        target_surface,
5544        first,
5545        last,
5546        tolerance,
5547        0,
5548        &mut samples,
5549    )?;
5550    Some(PcurveGeometry::Nurbs {
5551        degree: 1,
5552        knots: linear_knots(&samples.iter().map(|sample| sample.0).collect::<Vec<_>>()),
5553        control_points: samples.iter().map(|sample| sample.1).collect(),
5554        weights: None,
5555        periodic: false,
5556    })
5557}
5558
5559type TransferredPcurveSample = (f64, Point2, Point3);
5560
5561#[allow(clippy::too_many_arguments)]
5562fn transferred_pcurve_sample(
5563    ir: &CadIr,
5564    curve: &CurveId,
5565    source_surface: &SurfaceId,
5566    source_pcurve: &PcurveGeometry,
5567    target_surface: &SurfaceId,
5568    parameter: f64,
5569    seed: Option<Point2>,
5570    tolerance: f64,
5571) -> Option<TransferredPcurveSample> {
5572    let source_uv = pcurve_uv(source_pcurve, parameter)?;
5573    let point = decoded_surface_point(ir, source_surface, source_uv.u, source_uv.v)
5574        .or_else(|| model_curve_point(ir, curve, parameter))?;
5575    let target_uv = blend_boundary_parameter_from_support_pcurve(
5576        ir,
5577        target_surface,
5578        source_surface,
5579        source_pcurve,
5580        parameter,
5581        point,
5582        tolerance,
5583    )
5584    .or_else(|| {
5585        blend_boundary_parameter_from_support_spine(
5586            ir,
5587            target_surface,
5588            source_surface,
5589            point,
5590            seed,
5591            tolerance,
5592        )
5593    })
5594    .or_else(|| surface_parameters_for_fit(ir, target_surface, point, seed, tolerance))?;
5595    (decoded_surface_point(ir, target_surface, target_uv.u, target_uv.v)
5596        .is_some_and(|candidate| point_distance(candidate, point) <= tolerance)
5597        || blend_boundary_spine_geometry_matches(ir, target_surface, target_uv, point, tolerance))
5598    .then_some((parameter, target_uv, point))
5599}
5600
5601pub(crate) fn blend_boundary_parameter_from_support_spine(
5602    ir: &CadIr,
5603    blend: &SurfaceId,
5604    support: &SurfaceId,
5605    point: Point3,
5606    seed: Option<Point2>,
5607    tolerance: f64,
5608) -> Option<Point2> {
5609    let (supports, spine, _, _) = blend_surface_definition(ir, blend)?;
5610    let matches = supports
5611        .iter()
5612        .enumerate()
5613        .filter(|(_, candidate)| parameterization_equivalent_surfaces(ir, candidate, support))
5614        .map(|(boundary, _)| boundary)
5615        .collect::<Vec<_>>();
5616    let [boundary] = matches.as_slice() else {
5617        return None;
5618    };
5619    let parameter = closest_spine_parameter(ir, &spine, point, seed.map(|seed| seed.u))?;
5620    let parameters = Point2::new(parameter, *boundary as f64);
5621    (blend_surface_point_inner(ir, blend, parameters.u, parameters.v, 0)
5622        .is_some_and(|candidate| point_distance(candidate, point) <= tolerance)
5623        || blend_boundary_spine_geometry_matches(ir, blend, parameters, point, tolerance))
5624    .then_some(parameters)
5625}
5626
5627fn blend_boundary_spine_geometry_matches(
5628    ir: &CadIr,
5629    blend: &SurfaceId,
5630    parameters: Point2,
5631    point: Point3,
5632    tolerance: f64,
5633) -> bool {
5634    if parameters.v.to_bits() != 0.0f64.to_bits() && parameters.v.to_bits() != 1.0f64.to_bits() {
5635        return false;
5636    }
5637    let Some((_, spine, radius, _)) = blend_surface_definition(ir, blend) else {
5638        return false;
5639    };
5640    let Some(center) = model_curve_point(ir, &spine, parameters.u) else {
5641        return false;
5642    };
5643    let radial = Vector3::new(point.x - center.x, point.y - center.y, point.z - center.z);
5644    let distance = (radial.x * radial.x + radial.y * radial.y + radial.z * radial.z).sqrt();
5645    if !distance.is_finite() || (distance - radius).abs() > tolerance {
5646        return false;
5647    }
5648    let Some(radial) = unit_vector(radial) else {
5649        return false;
5650    };
5651    let Some(tangent) = model_curve_tangent(ir, &spine, parameters.u) else {
5652        return false;
5653    };
5654    let angular_tolerance = (tolerance / radius).max(1.0e-8);
5655    (radial.x * tangent.x + radial.y * tangent.y + radial.z * tangent.z).abs() <= angular_tolerance
5656}
5657
5658#[allow(clippy::too_many_arguments)]
5659fn append_transferred_pcurve_segment(
5660    ir: &CadIr,
5661    curve: &CurveId,
5662    source_surface: &SurfaceId,
5663    source_pcurve: &PcurveGeometry,
5664    target_surface: &SurfaceId,
5665    first: TransferredPcurveSample,
5666    last: TransferredPcurveSample,
5667    tolerance: f64,
5668    depth: usize,
5669    samples: &mut Vec<TransferredPcurveSample>,
5670) -> Option<()> {
5671    let midpoint_parameter = f64::midpoint(first.0, last.0);
5672    let midpoint_seed = Point2::new(
5673        f64::midpoint(first.1.u, last.1.u),
5674        f64::midpoint(first.1.v, last.1.v),
5675    );
5676    let midpoint = transferred_pcurve_sample(
5677        ir,
5678        curve,
5679        source_surface,
5680        source_pcurve,
5681        target_surface,
5682        midpoint_parameter,
5683        Some(midpoint_seed),
5684        tolerance,
5685    )?;
5686    let fits = [0.25, 0.5, 0.75].into_iter().all(|fraction| {
5687        let parameter = first.0 + fraction * (last.0 - first.0);
5688        let uv = Point2::new(
5689            first.1.u + fraction * (last.1.u - first.1.u),
5690            first.1.v + fraction * (last.1.v - first.1.v),
5691        );
5692        let Some(source_uv) = pcurve_uv(source_pcurve, parameter) else {
5693            return false;
5694        };
5695        let Some(source_point) =
5696            decoded_surface_point(ir, source_surface, source_uv.u, source_uv.v)
5697                .or_else(|| model_curve_point(ir, curve, parameter))
5698        else {
5699            return false;
5700        };
5701        decoded_surface_point(ir, target_surface, uv.u, uv.v)
5702            .is_some_and(|target_point| point_distance(source_point, target_point) <= tolerance)
5703            || blend_boundary_spine_geometry_matches(
5704                ir,
5705                target_surface,
5706                uv,
5707                source_point,
5708                tolerance,
5709            )
5710    });
5711    if fits {
5712        samples.push(last);
5713        return Some(());
5714    }
5715    (depth < 16).then_some(())?;
5716    append_transferred_pcurve_segment(
5717        ir,
5718        curve,
5719        source_surface,
5720        source_pcurve,
5721        target_surface,
5722        first,
5723        midpoint,
5724        tolerance,
5725        depth + 1,
5726        samples,
5727    )?;
5728    append_transferred_pcurve_segment(
5729        ir,
5730        curve,
5731        source_surface,
5732        source_pcurve,
5733        target_surface,
5734        midpoint,
5735        last,
5736        tolerance,
5737        depth + 1,
5738        samples,
5739    )
5740}
5741
5742fn surface_parameters_for_fit(
5743    ir: &CadIr,
5744    surface: &SurfaceId,
5745    point: Point3,
5746    seed: Option<Point2>,
5747    tolerance: f64,
5748) -> Option<Point2> {
5749    let carrier = ir
5750        .model
5751        .surfaces
5752        .iter()
5753        .find(|candidate| &candidate.id == surface)?;
5754    match &carrier.geometry {
5755        SurfaceGeometry::Nurbs(nurbs) => nurbs_parameters(nurbs, point, seed),
5756        SurfaceGeometry::Procedural { .. } => {
5757            offset_surface_parameters_with_tolerance(ir, surface, point, seed, Some(tolerance))
5758                .or_else(|| blend_surface_parameters_for_fit(ir, surface, point, seed, tolerance))
5759        }
5760        geometry => analytic_surface_parameters(geometry, point),
5761    }
5762}
5763
5764pub(crate) fn attach_tolerant_edge_intersections(
5765    ir: &mut CadIr,
5766    graph: &Graph,
5767    edges: &BTreeMap<u32, EdgeId>,
5768    prefix: &str,
5769    source_stream: cadmpeg_ir::annotations::StreamHandle,
5770    annotations: &mut AnnotationBuilder,
5771) {
5772    let mut candidates = Vec::new();
5773    for (&xmt, edge_id) in edges {
5774        let Some(edge) = ir
5775            .model
5776            .edges
5777            .iter()
5778            .find(|candidate| &candidate.id == edge_id)
5779        else {
5780            continue;
5781        };
5782        if edge.curve.is_some() || edge.tolerance.is_none() {
5783            continue;
5784        }
5785        let mut supports = ir
5786            .model
5787            .coedges
5788            .iter()
5789            .filter(|coedge| &coedge.edge == edge_id)
5790            .filter_map(|coedge| {
5791                let face = ir
5792                    .model
5793                    .loops
5794                    .iter()
5795                    .find(|loop_| loop_.id == coedge.owner_loop)?
5796                    .face
5797                    .clone();
5798                ir.model
5799                    .faces
5800                    .iter()
5801                    .find(|candidate| candidate.id == face)
5802                    .map(|face| face.surface.clone())
5803            })
5804            .collect::<BTreeSet<_>>();
5805        if supports.len() != 2 {
5806            continue;
5807        }
5808        let second = supports.pop_last().expect("two supports");
5809        let first = supports.pop_first().expect("two supports");
5810        candidates.push((xmt, edge_id.clone(), [first, second]));
5811    }
5812
5813    for (xmt, edge_id, supports) in candidates {
5814        let curve_id = CurveId(format!("{prefix}:tolerant-curve#{xmt}"));
5815        let procedural_id = ProceduralCurveId(format!("{prefix}:tolerant-intersection#{xmt}"));
5816        let Some(edge) = ir
5817            .model
5818            .edges
5819            .iter_mut()
5820            .find(|candidate| candidate.id == edge_id)
5821        else {
5822            continue;
5823        };
5824        edge.curve = Some(curve_id.clone());
5825        edge.param_range = Some([0.0, 1.0]);
5826        annotations.derived(&edge_id, "curve");
5827        annotations.derived(&edge_id, "param_range");
5828        if let Some(node) = graph.get(16, xmt) {
5829            annotations
5830                .note(&curve_id, source_stream, node.pos as u64)
5831                .tag("TOLERANT_EDGE_INTERSECTION");
5832            annotations
5833                .note(&procedural_id, source_stream, node.pos as u64)
5834                .tag("TOLERANT_EDGE_INTERSECTION");
5835        }
5836        annotations.derived(&curve_id, "geometry");
5837        annotations.derived(&procedural_id, "definition");
5838        ir.model.curves.push(Curve {
5839            id: curve_id.clone(),
5840            geometry: CurveGeometry::Procedural {
5841                construction: procedural_id.clone(),
5842            },
5843            source_object: None,
5844        });
5845        ir.model.procedural_curves.push(ProceduralCurve {
5846            id: procedural_id,
5847            curve: curve_id,
5848            definition: ProceduralCurveDefinition::Intersection {
5849                context: IntcurveSupportContext {
5850                    sides: supports.map(|surface| IntcurveSupportSide {
5851                        surface: Some(surface),
5852                        pcurve: None,
5853                        pcurve_parameter_range: None,
5854                    }),
5855                    parameter_range: [0.0, 1.0],
5856                    discontinuities: [Vec::new(), Vec::new(), Vec::new()],
5857                },
5858                discontinuity_flag: false,
5859            },
5860            cache_fit_tolerance: None,
5861        });
5862    }
5863}
5864
5865pub(crate) fn pcurve_matches_edge(
5866    ir: &CadIr,
5867    edge_id: &EdgeId,
5868    surface_id: &SurfaceId,
5869    geometry: &PcurveGeometry,
5870    fit_tolerance: Option<f64>,
5871) -> bool {
5872    pcurve_matches_edge_range(ir, edge_id, surface_id, geometry, None, fit_tolerance)
5873}
5874
5875fn pcurve_matches_edge_range(
5876    ir: &CadIr,
5877    edge_id: &EdgeId,
5878    surface_id: &SurfaceId,
5879    geometry: &PcurveGeometry,
5880    parameter_range: Option<[f64; 2]>,
5881    fit_tolerance: Option<f64>,
5882) -> bool {
5883    let Some(edge) = ir.model.edges.iter().find(|edge| &edge.id == edge_id) else {
5884        return false;
5885    };
5886    let Some(coincident_surface) = ir
5887        .model
5888        .surfaces
5889        .iter()
5890        .find(|surface| &surface.id == surface_id)
5891        .and_then(|surface| {
5892            let [t0, t1] = parameter_range.or_else(|| pcurve_parameter_range(geometry))?;
5893            let uv = [pcurve_uv(geometry, t0)?, pcurve_uv(geometry, t1)?];
5894            Some([
5895                surface_point(&surface.geometry, uv[0].u, uv[0].v)?,
5896                surface_point(&surface.geometry, uv[1].u, uv[1].v)?,
5897            ])
5898        })
5899    else {
5900        return false;
5901    };
5902    let vertex = |id: &VertexId| {
5903        let vertex = ir.model.vertices.iter().find(|vertex| &vertex.id == id)?;
5904        let point = ir
5905            .model
5906            .points
5907            .iter()
5908            .find(|point| point.id == vertex.point)?;
5909        Some((point.position, vertex.tolerance))
5910    };
5911    let (Some((start, start_tolerance)), Some((end, end_tolerance))) =
5912        (vertex(&edge.start), vertex(&edge.end))
5913    else {
5914        return false;
5915    };
5916    let allowance = [
5917        edge.tolerance,
5918        start_tolerance,
5919        end_tolerance,
5920        fit_tolerance,
5921    ]
5922    .into_iter()
5923    .flatten()
5924    .fold(0.01_f64, f64::max);
5925    let distance = |a: cadmpeg_ir::math::Point3, b: cadmpeg_ir::math::Point3| {
5926        ((a.x - b.x).powi(2) + (a.y - b.y).powi(2) + (a.z - b.z).powi(2)).sqrt()
5927    };
5928    (distance(coincident_surface[0], start) <= allowance
5929        && distance(coincident_surface[1], end) <= allowance)
5930        || (distance(coincident_surface[0], end) <= allowance
5931            && distance(coincident_surface[1], start) <= allowance)
5932}
5933
5934#[allow(clippy::too_many_arguments)]
5935fn retain_unresolved_topology_carriers(
5936    ir: &mut CadIr,
5937    stream_index: usize,
5938    graph: &Graph,
5939    surfaces: &mut BTreeMap<u32, SurfaceId>,
5940    curves: &mut BTreeMap<u32, CurveId>,
5941    pcurves: &BTreeMap<u32, PcurveId>,
5942    source_stream: cadmpeg_ir::annotations::StreamHandle,
5943    annotations: &mut AnnotationBuilder,
5944) {
5945    let unknown = UnknownId(format!("nx:container:parasolid#{stream_index}"));
5946    for face in graph.of_kind(14) {
5947        let Some(surface_xmt) = face.face_fields().map(|fields| fields.surface) else {
5948            continue;
5949        };
5950        if surface_xmt <= 1 || surfaces.contains_key(&surface_xmt) {
5951            continue;
5952        }
5953        let id = SurfaceId(format!("nx:s{stream_index}:surface#unknown-{surface_xmt}"));
5954        annotations
5955            .note(&id, source_stream, face.pos as u64)
5956            .tag("UNRESOLVED_SURFACE_REFERENCE");
5957        annotations.exactness(&id, Exactness::Unknown);
5958        ir.model.surfaces.push(Surface {
5959            id: id.clone(),
5960            geometry: SurfaceGeometry::Unknown {
5961                record: Some(unknown.clone()),
5962            },
5963            source_object: None,
5964        });
5965        surfaces.insert(surface_xmt, id);
5966    }
5967
5968    for edge in graph.of_kind(16) {
5969        let Some(curve_xmt) = edge.edge_fields().map(|fields| fields.curve) else {
5970            continue;
5971        };
5972        if curve_xmt <= 1 || curves.contains_key(&curve_xmt) || pcurves.contains_key(&curve_xmt) {
5973            continue;
5974        }
5975        let id = CurveId(format!("nx:s{stream_index}:curve#unknown-{curve_xmt}"));
5976        annotations
5977            .note(&id, source_stream, edge.pos as u64)
5978            .tag("UNRESOLVED_CURVE_REFERENCE");
5979        annotations.exactness(&id, Exactness::Unknown);
5980        ir.model.curves.push(Curve {
5981            id: id.clone(),
5982            geometry: CurveGeometry::Unknown {
5983                record: Some(unknown.clone()),
5984            },
5985            source_object: None,
5986        });
5987        curves.insert(curve_xmt, id);
5988    }
5989}
5990
5991fn annotate_node(
5992    annotations: &mut AnnotationBuilder,
5993    id: impl std::fmt::Display,
5994    stream: cadmpeg_ir::annotations::StreamHandle,
5995    node: &Node,
5996    tag: &str,
5997) {
5998    annotations.note(id, stream, node.pos as u64).tag(tag);
5999}
6000
6001fn surface_tag(geometry: &SurfaceGeometry) -> &'static str {
6002    match geometry {
6003        SurfaceGeometry::Plane { .. } => "PLANE",
6004        SurfaceGeometry::Cylinder { .. } => "CYLINDER",
6005        SurfaceGeometry::Cone { .. } => "CONE",
6006        SurfaceGeometry::Sphere { .. } => "SPHERE",
6007        SurfaceGeometry::Torus { .. } => "TORUS",
6008        SurfaceGeometry::Nurbs(_) => "B_SPLINE_SURFACE",
6009        SurfaceGeometry::Procedural { .. } => "PROCEDURAL_SURFACE",
6010        SurfaceGeometry::Polygonal { .. } => "POLYGONAL_SURFACE",
6011        SurfaceGeometry::Transformed { basis, .. } => surface_tag(basis),
6012        SurfaceGeometry::Unknown { .. } => "UNKNOWN_SURFACE",
6013    }
6014}
6015
6016fn curve_tag(geometry: &CurveGeometry) -> &'static str {
6017    match geometry {
6018        CurveGeometry::Line { .. } => "LINE",
6019        CurveGeometry::Circle { .. } => "CIRCLE",
6020        CurveGeometry::Ellipse { .. } => "ELLIPSE",
6021        CurveGeometry::Parabola { .. } => "PARABOLA",
6022        CurveGeometry::Hyperbola { .. } => "HYPERBOLA",
6023        CurveGeometry::Degenerate { .. } => "DEGENERATE_CURVE",
6024        CurveGeometry::Nurbs(_) => "B_SPLINE_CURVE",
6025        CurveGeometry::Procedural { .. } => "PROCEDURAL_CURVE",
6026        CurveGeometry::Composite { .. } => "COMPOSITE_CURVE",
6027        CurveGeometry::Polyline { .. } => "POLYLINE",
6028        CurveGeometry::Transformed { basis, .. } => curve_tag(basis),
6029        CurveGeometry::Unknown { .. } => "UNKNOWN_CURVE",
6030    }
6031}
6032
6033pub(crate) fn decoded_tolerance(value: f64) -> Option<f64> {
6034    match value {
6035        MISSING_TOLERANCE => None,
6036        value if value.is_finite() && value > 0.0 && (value * 1000.0).is_finite() => {
6037            Some(value * 1000.0)
6038        }
6039        _ => None,
6040    }
6041}
6042
6043#[allow(clippy::too_many_arguments)]
6044fn synthesize_closed_edge_vertex(
6045    ir: &mut CadIr,
6046    annotations: &mut AnnotationBuilder,
6047    prefix: &str,
6048    edge: &Node,
6049    curve: &CurveId,
6050    range: Option<[f64; 2]>,
6051    source_stream: cadmpeg_ir::annotations::StreamHandle,
6052    tolerance: Option<f64>,
6053) -> Option<VertexId> {
6054    let geometry = &ir
6055        .model
6056        .curves
6057        .iter()
6058        .find(|candidate| candidate.id == *curve)?
6059        .geometry;
6060    let parameter = range.map_or_else(
6061        || match geometry {
6062            CurveGeometry::Nurbs(nurbs) => nurbs.knots.first().copied().unwrap_or(0.0),
6063            _ => 0.0,
6064        },
6065        |range| range[0],
6066    );
6067    let position = curve_point(geometry, parameter)?;
6068    let point = PointId(format!("{prefix}:point#closed-edge-{}", edge.xmt));
6069    let vertex = VertexId(format!("{prefix}:vertex#closed-edge-{}", edge.xmt));
6070    annotations
6071        .note(&point, source_stream, edge.pos as u64)
6072        .tag("CLOSED_EDGE_POINT");
6073    annotations.exactness(&point, Exactness::Inferred);
6074    annotations
6075        .note(&vertex, source_stream, edge.pos as u64)
6076        .tag("CLOSED_EDGE_VERTEX");
6077    annotations.exactness(&vertex, Exactness::Inferred);
6078    ir.model.points.push(Point {
6079        id: point.clone(),
6080        position,
6081        source_object: None,
6082    });
6083    ir.model.vertices.push(Vertex {
6084        id: vertex.clone(),
6085        point,
6086        tolerance,
6087    });
6088    Some(vertex)
6089}
6090
6091fn canonical_trim_range(ir: &CadIr, basis: &CurveId, raw: [f64; 2]) -> Option<[f64; 2]> {
6092    let curve = ir.model.curves.iter().find(|curve| curve.id == *basis)?;
6093    match &curve.geometry {
6094        CurveGeometry::Line { .. } => {
6095            let range = [raw[0] * 1000.0, raw[1] * 1000.0];
6096            range.into_iter().all(f64::is_finite).then_some(range)
6097        }
6098        CurveGeometry::Nurbs(nurbs) => {
6099            let domain = [*nurbs.knots.first()?, *nurbs.knots.last()?];
6100            let epsilon = 1.0e-6 * (1.0 + domain[0].abs().max(domain[1].abs()));
6101            if raw
6102                .iter()
6103                .any(|value| *value < domain[0] - epsilon || *value > domain[1] + epsilon)
6104            {
6105                None
6106            } else {
6107                Some([
6108                    raw[0].clamp(domain[0], domain[1]),
6109                    raw[1].clamp(domain[0], domain[1]),
6110                ])
6111            }
6112        }
6113        _ => Some(raw),
6114    }
6115}
6116
6117fn orient_edge_range(
6118    ir: &CadIr,
6119    curve: &CurveId,
6120    range: [f64; 2],
6121    start: &VertexId,
6122    end: &VertexId,
6123    edge_tolerance: Option<f64>,
6124) -> Option<([f64; 2], bool)> {
6125    let geometry = &ir
6126        .model
6127        .curves
6128        .iter()
6129        .find(|candidate| candidate.id == *curve)?
6130        .geometry;
6131    let range = if range[0] <= range[1] {
6132        range
6133    } else {
6134        [range[1], range[0]]
6135    };
6136    let range = match geometry {
6137        CurveGeometry::Circle { .. } | CurveGeometry::Ellipse { .. } => {
6138            let sweep = range[1] - range[0];
6139            (0.0..=std::f64::consts::TAU)
6140                .contains(&sweep)
6141                .then_some(())?;
6142            let start = range[0].rem_euclid(std::f64::consts::TAU);
6143            [start, start + sweep]
6144        }
6145        _ => range,
6146    };
6147    let at = match (
6148        curve_point(geometry, range[0]),
6149        curve_point(geometry, range[1]),
6150    ) {
6151        (Some(start), Some(end)) => [start, end],
6152        _ if ir
6153            .model
6154            .procedural_curves
6155            .iter()
6156            .any(|procedural| procedural.curve == *curve) =>
6157        {
6158            return Some((range, false));
6159        }
6160        _ => return None,
6161    };
6162    let vertex_position = |vertex: &VertexId| {
6163        let vertex = ir
6164            .model
6165            .vertices
6166            .iter()
6167            .find(|candidate| candidate.id == *vertex)?;
6168        let point = ir
6169            .model
6170            .points
6171            .iter()
6172            .find(|candidate| candidate.id == vertex.point)?;
6173        Some((point.position, vertex.tolerance))
6174    };
6175    let (start_position, start_tolerance) = vertex_position(start)?;
6176    let (end_position, end_tolerance) = vertex_position(end)?;
6177    let cache_tolerance = ir
6178        .model
6179        .procedural_curves
6180        .iter()
6181        .find(|procedural| procedural.curve == *curve)
6182        .and_then(|procedural| procedural.cache_fit_tolerance);
6183    let allowance = [
6184        edge_tolerance,
6185        start_tolerance,
6186        end_tolerance,
6187        cache_tolerance,
6188    ]
6189    .into_iter()
6190    .flatten()
6191    .fold(0.01_f64, f64::max);
6192    let distance = |a: cadmpeg_ir::math::Point3, b: cadmpeg_ir::math::Point3| {
6193        ((a.x - b.x).powi(2) + (a.y - b.y).powi(2) + (a.z - b.z).powi(2)).sqrt()
6194    };
6195    if distance(at[0], start_position) <= allowance && distance(at[1], end_position) <= allowance {
6196        Some((range, false))
6197    } else if distance(at[1], start_position) <= allowance
6198        && distance(at[0], end_position) <= allowance
6199    {
6200        Some((range, true))
6201    } else {
6202        None
6203    }
6204}
6205
6206fn sense(byte: Option<u8>) -> Sense {
6207    if byte == Some(b'-') {
6208        Sense::Reversed
6209    } else {
6210        Sense::Forward
6211    }
6212}
6213
6214fn unknown_stream(si: usize, stream: &Stream) -> UnknownRecord {
6215    UnknownRecord {
6216        id: UnknownId(format!("nx:container:parasolid#{si}")),
6217        offset: stream.file_offset as u64,
6218        byte_len: stream.inflated.len() as u64,
6219        sha256: sha256_hex(&stream.inflated),
6220        data: Some(stream.inflated.clone()),
6221        links: Vec::new(),
6222    }
6223}
6224
6225fn source_meta(scan: &Scan) -> SourceMeta {
6226    let mut attributes = BTreeMap::new();
6227    attributes.insert(
6228        "file_size".to_string(),
6229        scan.container.data.len().to_string(),
6230    );
6231    attributes.insert(
6232        "footer_offset".to_string(),
6233        scan.container.footer_offset.to_string(),
6234    );
6235    attributes.insert(
6236        "directory_entries".to_string(),
6237        scan.container.entries.len().to_string(),
6238    );
6239    attributes.insert(
6240        "partition_streams".to_string(),
6241        scan.count(StreamKind::Partition).to_string(),
6242    );
6243    attributes.insert(
6244        "deltas_streams".to_string(),
6245        scan.count(StreamKind::Deltas).to_string(),
6246    );
6247    attributes.insert(
6248        "plain_streams".to_string(),
6249        scan.count(StreamKind::Plain).to_string(),
6250    );
6251    if let Some(schema) = scan.streams.iter().find_map(|s| s.schema.as_deref()) {
6252        attributes.insert("parasolid_schema".to_string(), schema.to_string());
6253    }
6254    for (index, path) in scan
6255        .container
6256        .external_reference_paths()
6257        .into_iter()
6258        .enumerate()
6259    {
6260        attributes.insert(format!("external_reference.{index}"), path);
6261    }
6262    if let Some((_, table)) = scan.container.rmfastload_object_id_table() {
6263        attributes.insert(
6264            "rmfastload_active_object_count".to_string(),
6265            table.object_ids.len().to_string(),
6266        );
6267    }
6268    let mut preview_count = 0usize;
6269    for entry in scan
6270        .container
6271        .entries
6272        .iter()
6273        .filter(|entry| entry.name == "/Root/images/preview")
6274    {
6275        let Some((offset, size)) = entry.file_span else {
6276            continue;
6277        };
6278        let (Ok(start), Ok(size)) = (usize::try_from(offset), usize::try_from(size)) else {
6279            continue;
6280        };
6281        let Some(payload) = scan.container.data.get(start..start.saturating_add(size)) else {
6282            continue;
6283        };
6284        let Some((width, height, precision, components)) = jpeg_dimensions(payload) else {
6285            continue;
6286        };
6287        let prefix = format!("jpeg_preview_{preview_count}");
6288        attributes.insert(format!("{prefix}_width"), width.to_string());
6289        attributes.insert(format!("{prefix}_height"), height.to_string());
6290        attributes.insert(format!("{prefix}_precision"), precision.to_string());
6291        attributes.insert(format!("{prefix}_components"), components.to_string());
6292        attributes.insert(format!("{prefix}_byte_len"), payload.len().to_string());
6293        attributes.insert(format!("{prefix}_sha256"), sha256_hex(payload));
6294        preview_count += 1;
6295    }
6296    attributes.insert("jpeg_preview_count".to_string(), preview_count.to_string());
6297    for (index, stream) in scan
6298        .streams
6299        .iter()
6300        .filter(|stream| stream.kind == StreamKind::Deltas)
6301        .enumerate()
6302    {
6303        let census = crate::deltas::walk(&stream.inflated);
6304        attributes.insert(
6305            format!("deltas.{index}.grammar"),
6306            "status_byte_framed_topology".to_string(),
6307        );
6308        attributes.insert(
6309            format!("deltas.{index}.bytes_decoded"),
6310            census.bytes_decoded.to_string(),
6311        );
6312        for (name, count) in census.full_counts {
6313            attributes.insert(format!("deltas.{index}.full.{name}"), count.to_string());
6314        }
6315        for (name, count) in census.tombstone_counts {
6316            attributes.insert(
6317                format!("deltas.{index}.tombstone.{name}"),
6318                count.to_string(),
6319            );
6320        }
6321    }
6322    SourceMeta {
6323        format: "nx".to_string(),
6324        attributes,
6325    }
6326}
6327
6328pub(crate) fn jpeg_dimensions(payload: &[u8]) -> Option<(u16, u16, u8, u8)> {
6329    if payload.get(..2)? != [0xff, 0xd8] {
6330        return None;
6331    }
6332    let mut offset = 2usize;
6333    while offset < payload.len() {
6334        while payload.get(offset) == Some(&0xff) {
6335            offset += 1;
6336        }
6337        let marker = *payload.get(offset)?;
6338        offset += 1;
6339        if marker == 0xd9 || marker == 0xda {
6340            return None;
6341        }
6342        if marker == 0x01 || (0xd0..=0xd7).contains(&marker) {
6343            continue;
6344        }
6345        let length = usize::from(u16::from_be_bytes([
6346            *payload.get(offset)?,
6347            *payload.get(offset + 1)?,
6348        ]));
6349        if length < 2 {
6350            return None;
6351        }
6352        let segment_start = offset + 2;
6353        let segment_end = offset.checked_add(length)?;
6354        let segment = payload.get(segment_start..segment_end)?;
6355        if matches!(marker, 0xc0..=0xc3 | 0xc5..=0xc7 | 0xc9..=0xcb | 0xcd..=0xcf) {
6356            let precision = *segment.first()?;
6357            let height = u16::from_be_bytes([*segment.get(1)?, *segment.get(2)?]);
6358            let width = u16::from_be_bytes([*segment.get(3)?, *segment.get(4)?]);
6359            let components = *segment.get(5)?;
6360            if width == 0
6361                || height == 0
6362                || components == 0
6363                || segment.len() != 6 + 3 * usize::from(components)
6364            {
6365                return None;
6366            }
6367            return Some((width, height, precision, components));
6368        }
6369        offset = segment_end;
6370    }
6371    None
6372}
6373
6374fn build_geometry_report(
6375    scan: &Scan,
6376    ir: &CadIr,
6377    counts: &Counts,
6378    has_topology: bool,
6379    has_unresolved_sub_bodies: bool,
6380    tessellation_count: usize,
6381) -> DecodeReport {
6382    let mut losses = Vec::new();
6383
6384    losses.push(LossNote {
6385        code: LossCode::CarrierSummary,
6386        category: LossCategory::Geometry,
6387        severity: Severity::Info,
6388        message: format!(
6389            "Decoded {} POINT carrier(s) verbatim from Parasolid POINT records (3×f64 big-endian, \
6390             metres → millimetres), {} analytic surface carrier(s) ({} plane, {} cylinder, {} \
6391             cone, {} sphere, {} torus), and {} analytic curve carrier(s) ({} line, {} circle, {} \
6392             ellipse). All parameters are byte-exact at the document's millimetre scale.",
6393            counts.points,
6394            counts.surfaces(),
6395            counts.planes,
6396            counts.cylinders,
6397            counts.cones,
6398            counts.spheres,
6399            counts.tori,
6400            counts.curves(),
6401            counts.lines,
6402            counts.circles,
6403            counts.ellipses,
6404        ),
6405        provenance: None,
6406    });
6407
6408    if tessellation_count != 0 {
6409        losses.push(LossNote {
6410            code: LossCode::CarrierSummary,
6411            category: LossCategory::Geometry,
6412            severity: Severity::Info,
6413            message: format!(
6414                "Decoded {tessellation_count} embedded JT display tessellation(s) with scene-node ownership, model-space coordinates, topological triangle connectivity, and corner normals when bound."
6415            ),
6416            provenance: None,
6417        });
6418    }
6419
6420    if !has_topology {
6421        losses.push(LossNote {
6422            code: LossCode::TopologyNotTransferred,
6423            category: LossCategory::Topology,
6424            severity: Severity::Blocking,
6425            message: "The B-rep topology graph (body→shell→face→loop→fin→edge→vertex) was not \
6426                      reconstructed because the surviving typed records did not form a complete \
6427                      connected ownership graph. Exact-key supported partition↔deltas replacements \
6428                      and deletions were applied before graph construction. Required unresolved \
6429                      records prevent their dependent incidence from being emitted; decoded geometry \
6430                      then remains unattached."
6431                .to_string(),
6432            provenance: None,
6433        });
6434    }
6435
6436    if counts.intersection_rejections.total() > 0 {
6437        losses.push(LossNote {
6438            code: LossCode::ObjectRecordsUntransferred,
6439            category: LossCategory::Geometry,
6440            severity: Severity::Warning,
6441            message: format!(
6442                "{} surface-intersection record(s) without a complete validated CHART_s and \
6443                 term-endpoint witness remain opaque constructions. Support-UV values govern \
6444                 optional pcurve attachment and do not invalidate a witnessed 3D carrier. Each \
6445                 Parasolid stream is preserved verbatim as an unknown passthrough record so the \
6446                 unresolved source bytes remain available. Rejections: {} missing chart, {} missing \
6447                 start term, {} missing end term, {} endpoint mismatch.",
6448                counts.intersection_rejections.total(),
6449                counts.intersection_rejections.missing_chart,
6450                counts.intersection_rejections.missing_start_term,
6451                counts.intersection_rejections.missing_end_term,
6452                counts.intersection_rejections.endpoint_mismatch,
6453            ),
6454            provenance: None,
6455        });
6456    }
6457
6458    if scan.count(StreamKind::Deltas) > 0 {
6459        let unmatched_tombstones = unmatched_delta_tombstone_count(scan);
6460        losses.push(LossNote {
6461            code: LossCode::DecodeDiagnostic,
6462            category: LossCategory::Topology,
6463            severity: if unmatched_tombstones == 0 {
6464                Severity::Info
6465            } else {
6466                Severity::Warning
6467            },
6468            message: if unmatched_tombstones == 0 {
6469                format!(
6470                    "{} Parasolid deltas stream(s) were processed in validated UG_PART segment order. \
6471                 Equal-schema deltas were paired with the preceding partition. Exact-key \
6472                 BODY, SHELL, FACE, LOOP, FIN, EDGE, VERTEX, REGION, POINT, LINE, CIRCLE, ELLIPSE, PLANE, CYLINDER, CONE, SPHERE, TORUS, BLEND_SURF, OFFSET_SURF, B_SURFACE, TRIMMED_CURVE, B_CURVE, and SP_CURVE full records and compact \
6473                 non-topology replacements and tombstones were applied using the last event for \
6474                 each key. Validated partition topology remained authoritative, including any \
6475                 point, curve, or surface carrier still referenced by surviving topology. Every \
6476                 terminal tombstone resolved to an exact current or earlier-added key.",
6477                    scan.count(StreamKind::Deltas)
6478                )
6479            } else {
6480                format!(
6481                    "{} Parasolid deltas stream(s) were processed in validated UG_PART segment order. \
6482                 Equal-schema deltas were paired with the preceding partition. Exact-key revisions were applied using the last \
6483                 event for each key, but {unmatched_tombstones} terminal tombstone(s) have no exact \
6484                 current or earlier-added key and remain unresolved.",
6485                    scan.count(StreamKind::Deltas)
6486                )
6487            },
6488            provenance: None,
6489        });
6490    }
6491
6492    if has_unresolved_sub_bodies {
6493        losses.push(LossNote {
6494            code: LossCode::FeatureHistoryRetained,
6495            category: LossCategory::Topology,
6496            severity: Severity::Warning,
6497            message: format!(
6498                "This part is composed of {} sub-body partition(s); its decoded feature-history \
6499                 Booleans do not resolve every intermediate body object to a partition image. \
6500                 Carriers from all sub-bodies are emitted without the unresolved composition that \
6501                 would remove interior/construction faces.",
6502                scan.count(StreamKind::Partition)
6503            ),
6504            provenance: None,
6505        });
6506    }
6507
6508    append_design_intent_losses(ir, &mut losses);
6509
6510    losses.push(LossNote {
6511        code: LossCode::AttributesNotTransferred,
6512        category: LossCategory::Attribute,
6513        severity: Severity::Warning,
6514        message: "Material and appearance assignment, class-specific entity attribute fields, and \
6515                  assembly occurrence placements were not transferred: their remaining NX \
6516                  object-model and Parasolid field serialization is not decoded."
6517            .to_string(),
6518        provenance: None,
6519    });
6520
6521    DecodeReport {
6522        format: "nx".to_string(),
6523        container_only: false,
6524        geometry_transferred: true,
6525        coverage: std::collections::BTreeMap::new(),
6526        losses,
6527        notes: summary_notes(scan),
6528    }
6529}
6530
6531pub(crate) fn append_design_intent_losses(ir: &CadIr, losses: &mut Vec<LossNote>) {
6532    let unresolved_suppression_count = ir
6533        .model
6534        .features
6535        .iter()
6536        .filter(|feature| feature.suppressed.is_none())
6537        .count();
6538    if unresolved_suppression_count != 0 {
6539        losses.push(LossNote {
6540            code: LossCode::FeatureHistoryRetained,
6541            category: LossCategory::DesignIntent,
6542            severity: Severity::Warning,
6543            message: format!(
6544                "Suppression state remains unresolved for {unresolved_suppression_count} NX \
6545                 feature history operation(s)."
6546            ),
6547            provenance: None,
6548        });
6549    }
6550
6551    let active_configuration_count = ir
6552        .model
6553        .configurations
6554        .iter()
6555        .filter(|configuration| configuration.active)
6556        .count();
6557    let current_bodies = ir
6558        .model
6559        .bodies
6560        .iter()
6561        .map(|body| &body.id)
6562        .collect::<BTreeSet<_>>();
6563    let incomplete_configuration_count = ir
6564        .model
6565        .configurations
6566        .iter()
6567        .filter(|configuration| {
6568            configuration.bodies.is_unresolved()
6569                || active_configuration_count != 1
6570                || (configuration.active
6571                    && configuration.bodies.resolved().is_none_or(|bodies| {
6572                        bodies.len() != current_bodies.len()
6573                            || bodies.iter().collect::<BTreeSet<_>>() != current_bodies
6574                    }))
6575        })
6576        .count();
6577    if incomplete_configuration_count != 0 {
6578        losses.push(LossNote {
6579            code: LossCode::FeatureHistoryRetained,
6580            category: LossCategory::DesignIntent,
6581            severity: Severity::Warning,
6582            message: format!(
6583                "Activation or complete body membership remains unresolved for \
6584                 {incomplete_configuration_count} NX design configuration(s)."
6585            ),
6586            provenance: None,
6587        });
6588    }
6589
6590    let incomplete_expression_count = incomplete_expression_parameters(ir).len();
6591    if incomplete_expression_count != 0 {
6592        losses.push(LossNote {
6593            code: LossCode::FeatureHistoryRetained,
6594            category: LossCategory::DesignIntent,
6595            severity: Severity::Warning,
6596            message: format!(
6597                "Neutral evaluation or dependency semantics remain incomplete for \
6598                 {incomplete_expression_count} NX expression parameter(s)."
6599            ),
6600            provenance: None,
6601        });
6602    }
6603
6604    let mut native_feature_kinds = BTreeMap::<&str, usize>::new();
6605    for feature in &ir.model.features {
6606        if let FeatureDefinition::Native { kind, .. } = &feature.definition {
6607            *native_feature_kinds.entry(kind.as_str()).or_default() += 1;
6608        }
6609    }
6610    if !native_feature_kinds.is_empty() {
6611        let kinds = native_feature_kinds
6612            .into_iter()
6613            .map(|(kind, count)| format!("{kind} ({count})"))
6614            .collect::<Vec<_>>()
6615            .join(", ");
6616        losses.push(LossNote {
6617            code: LossCode::FeatureHistoryRetained,
6618            category: LossCategory::DesignIntent,
6619            severity: Severity::Warning,
6620            message: format!(
6621                "NX feature-history operation(s) remain native-only because their complete neutral \
6622                 operation semantics are not decoded: {kinds}."
6623            ),
6624            provenance: None,
6625        });
6626    }
6627
6628    let mut unresolved_feature_families = BTreeMap::<&str, usize>::new();
6629    for feature in &ir.model.features {
6630        let family = match feature.definition {
6631            FeatureDefinition::DatumPlaneUnresolved => "datum plane",
6632            FeatureDefinition::DatumPointUnresolved => "datum point",
6633            FeatureDefinition::DatumCoordinateSystemUnresolved => "datum coordinate system",
6634            FeatureDefinition::LoftUnresolved => "loft",
6635            FeatureDefinition::FreeformSurfaceUnresolved => "freeform surface",
6636            FeatureDefinition::DraftUnresolved => "draft",
6637            _ => continue,
6638        };
6639        *unresolved_feature_families.entry(family).or_default() += 1;
6640    }
6641    if !unresolved_feature_families.is_empty() {
6642        let families = unresolved_feature_families
6643            .into_iter()
6644            .map(|(family, count)| format!("{family} ({count})"))
6645            .collect::<Vec<_>>()
6646            .join(", ");
6647        losses.push(LossNote {
6648            code: LossCode::FeatureHistoryRetained,
6649            category: LossCategory::DesignIntent,
6650            severity: Severity::Warning,
6651            message: format!(
6652                "NX feature family identities were transferred, but their neutral construction \
6653                 semantics remain unresolved: {families}."
6654            ),
6655            provenance: None,
6656        });
6657    }
6658
6659    let mut incomplete_feature_families = BTreeMap::<&str, usize>::new();
6660    for feature in &ir.model.features {
6661        if feature.suppressed != Some(true) {
6662            if let Some(family) = body_output_feature_family(&feature.definition).filter(|_| {
6663                feature.outputs.is_empty()
6664                    || feature.outputs.iter().collect::<BTreeSet<_>>().len()
6665                        != feature.outputs.len()
6666                    || feature
6667                        .outputs
6668                        .iter()
6669                        .any(|output| !ir.model.bodies.iter().any(|body| body.id == *output))
6670            }) {
6671                *incomplete_feature_families.entry(family).or_default() += 1;
6672                continue;
6673            }
6674        }
6675        let family = match &feature.definition {
6676            FeatureDefinition::Block {
6677                dimensions,
6678                placement,
6679            } if dimensions.is_none() || placement.is_none() => "block",
6680            FeatureDefinition::DatumOffsetPlane {
6681                reference,
6682                distance,
6683            } if !distance.0.is_finite()
6684                || reference.as_ref().is_none_or(|reference| {
6685                    ir.model
6686                        .features
6687                        .iter()
6688                        .find(|candidate| candidate.id == *reference)
6689                        .is_none_or(|source| source.ordinal >= feature.ordinal)
6690                        || !feature.dependencies.contains(reference)
6691                }) =>
6692            {
6693                "datum plane"
6694            }
6695            FeatureDefinition::ExtractBody { source } if body_selection_is_incomplete(source) => {
6696                "extract body"
6697            }
6698            FeatureDefinition::Sketch { space, sketch }
6699                if !matches!(space, SketchSpace::Planar) || sketch.is_none() =>
6700            {
6701                "sketch"
6702            }
6703            FeatureDefinition::Loft {
6704                sections,
6705                guides,
6706                op,
6707                ..
6708            } if sections.len() < 2
6709                || sections.iter().any(|section| match section {
6710                    cadmpeg_ir::features::LoftSection::Profile(profile) => {
6711                        profile_ref_is_incomplete(profile)
6712                    }
6713                    cadmpeg_ir::features::LoftSection::Point { .. } => false,
6714                })
6715                || guides.iter().any(path_ref_is_incomplete)
6716                || matches!(op, BooleanOp::Unresolved) =>
6717            {
6718                "loft"
6719            }
6720            FeatureDefinition::ProjectedCurve {
6721                source,
6722                target_faces,
6723                direction,
6724                bidirectional,
6725            } if path_ref_is_incomplete(source)
6726                || face_selection_is_incomplete(target_faces)
6727                || matches!(
6728                    direction,
6729                    CurveProjectionDirection::State(CurveProjectionDirectionState::Unresolved)
6730                )
6731                || bidirectional.is_none() =>
6732            {
6733                "projected curve"
6734            }
6735            FeatureDefinition::TrimSurface { faces, tool, keep }
6736                if face_selection_is_incomplete(faces)
6737                    || path_ref_is_incomplete(tool)
6738                    || matches!(keep, TrimRegion::Unresolved) =>
6739            {
6740                "trim surface"
6741            }
6742            FeatureDefinition::ExtendSurface {
6743                faces,
6744                distance,
6745                method,
6746            } if face_selection_is_incomplete(faces)
6747                || distance.is_none()
6748                || matches!(method, cadmpeg_ir::features::SurfaceExtension::Unresolved) =>
6749            {
6750                "extend surface"
6751            }
6752            FeatureDefinition::Hole {
6753                profile,
6754                face,
6755                position,
6756                direction,
6757                kind,
6758                exit_kind,
6759                diameter,
6760                extent,
6761                ..
6762            } if hole_feature_is_incomplete(
6763                profile.as_ref(),
6764                face.as_ref(),
6765                *position,
6766                *direction,
6767                (kind, exit_kind.as_ref()),
6768                *diameter,
6769                extent.as_ref(),
6770            ) =>
6771            {
6772                "hole"
6773            }
6774            FeatureDefinition::Rib { construction, op }
6775                if rib_feature_is_incomplete(construction, *op) =>
6776            {
6777                "rib"
6778            }
6779            FeatureDefinition::Chamfer { groups, .. }
6780                if groups.is_empty()
6781                    || groups.iter().any(|group| {
6782                        edge_selection_is_incomplete(&group.edges)
6783                            || matches!(group.spec, ChamferSpec::Unresolved { .. })
6784                    }) =>
6785            {
6786                "chamfer"
6787            }
6788            FeatureDefinition::Fillet { groups }
6789                if groups.is_empty()
6790                    || groups.iter().any(|group| {
6791                        edge_selection_is_incomplete(&group.edges)
6792                            || radius_spec_is_incomplete(&group.radius)
6793                    }) =>
6794            {
6795                "fillet"
6796            }
6797            FeatureDefinition::FaceBlend {
6798                first_faces,
6799                second_faces,
6800                radius,
6801            } if face_selection_is_incomplete(first_faces)
6802                || face_selection_is_incomplete(second_faces)
6803                || face_selections_overlap(first_faces, second_faces)
6804                || radius_spec_is_incomplete(radius) =>
6805            {
6806                "face blend"
6807            }
6808            FeatureDefinition::SewBodies { bodies, .. }
6809                if body_selection_is_incomplete(bodies)
6810                    || explicit_body_ids(bodies).is_some_and(|bodies| bodies.len() < 2) =>
6811            {
6812                "sew bodies"
6813            }
6814            FeatureDefinition::TrimBodies {
6815                targets,
6816                tools,
6817                keep,
6818            } if body_selection_is_incomplete(targets)
6819                || body_selection_is_incomplete(tools)
6820                || body_selections_overlap(targets, tools)
6821                || matches!(keep, BodyTrimSide::Unresolved) =>
6822            {
6823                "trim bodies"
6824            }
6825            FeatureDefinition::Extrude {
6826                profile,
6827                extent,
6828                op,
6829                ..
6830            } if profile_ref_is_incomplete(profile)
6831                || extrude_extent_is_incomplete(extent)
6832                || matches!(op, BooleanOp::Unresolved) =>
6833            {
6834                "extrude"
6835            }
6836            FeatureDefinition::OffsetSurface { faces, distance }
6837                if face_selection_is_incomplete(faces) || distance.is_none() =>
6838            {
6839                "offset surface"
6840            }
6841            FeatureDefinition::Thicken {
6842                faces,
6843                thickness,
6844                side,
6845            } if face_selection_is_incomplete(faces) || thickness.is_none() || side.is_none() => {
6846                "thicken"
6847            }
6848            FeatureDefinition::Draft {
6849                faces,
6850                neutral_plane,
6851                ..
6852            } if face_selection_is_incomplete(faces)
6853                || face_selection_is_incomplete(neutral_plane) =>
6854            {
6855                "draft"
6856            }
6857            FeatureDefinition::Pattern { seeds, pattern }
6858                if pattern_feature_is_incomplete(seeds, pattern) =>
6859            {
6860                "pattern"
6861            }
6862            FeatureDefinition::Combine { target, tools, op }
6863                if body_selection_is_incomplete(target)
6864                    || body_selection_is_incomplete(tools)
6865                    || body_selections_overlap(target, tools)
6866                    || matches!(op, BooleanOp::Unresolved) =>
6867            {
6868                "body combine"
6869            }
6870            FeatureDefinition::DeleteBody { bodies, mode }
6871                if body_selection_is_incomplete(bodies)
6872                    || matches!(mode, BodyRetentionMode::Unresolved) =>
6873            {
6874                "delete body"
6875            }
6876            _ => continue,
6877        };
6878        *incomplete_feature_families.entry(family).or_default() += 1;
6879    }
6880    if !incomplete_feature_families.is_empty() {
6881        let families = incomplete_feature_families
6882            .into_iter()
6883            .map(|(family, count)| format!("{family} ({count})"))
6884            .collect::<Vec<_>>()
6885            .join(", ");
6886        losses.push(LossNote {
6887            code: LossCode::FeatureHistoryRetained,
6888            category: LossCategory::DesignIntent,
6889            severity: Severity::Warning,
6890            message: format!(
6891                "NX feature families were transferred as typed neutral operations, but \
6892                 construction fields or output lineage remain unresolved or native-only: \
6893                 {families}."
6894            ),
6895            provenance: None,
6896        });
6897    }
6898
6899    let sketch_feature_count = ir
6900        .model
6901        .features
6902        .iter()
6903        .filter(|feature| matches!(feature.definition, FeatureDefinition::Sketch { .. }))
6904        .count();
6905    let unresolved_sketch_feature_count = ir
6906        .model
6907        .features
6908        .iter()
6909        .filter(|feature| {
6910            matches!(
6911                feature.definition,
6912                FeatureDefinition::Sketch { sketch: None, .. }
6913            )
6914        })
6915        .count();
6916    if unresolved_sketch_feature_count != 0 {
6917        losses.push(LossNote {
6918            code: LossCode::FeatureHistoryRetained,
6919            category: LossCategory::DesignIntent,
6920            severity: Severity::Warning,
6921            message: format!(
6922                "Decoded {sketch_feature_count} NX sketch history feature(s), of which \
6923                 {unresolved_sketch_feature_count} have no neutral sketch graph because complete \
6924                 sketch placement and entity semantics are unresolved."
6925            ),
6926            provenance: None,
6927        });
6928    } else if sketch_feature_count != 0 && ir.model.sketch_constraints.is_empty() {
6929        losses.push(LossNote {
6930            code: LossCode::FeatureHistoryRetained,
6931            category: LossCategory::DesignIntent,
6932            severity: Severity::Warning,
6933            message: format!(
6934                "Decoded {} NX sketch record(s), but no sketch constraints were transferred because \
6935                 their object-model field serialization and operand roles are unresolved.",
6936                ir.model.sketches.len()
6937            ),
6938            provenance: None,
6939        });
6940    }
6941
6942    let native_sketch_entity_count = ir
6943        .model
6944        .sketch_entities
6945        .iter()
6946        .filter(|entity| {
6947            matches!(
6948                entity.geometry,
6949                cadmpeg_ir::sketches::SketchGeometry::Native { .. }
6950            )
6951        })
6952        .count();
6953    let native_sketch_constraint_count = ir
6954        .model
6955        .sketch_constraints
6956        .iter()
6957        .filter(|constraint| {
6958            matches!(
6959                constraint.definition,
6960                cadmpeg_ir::sketches::SketchConstraintDefinition::Native { .. }
6961            )
6962        })
6963        .count();
6964    if native_sketch_entity_count != 0 || native_sketch_constraint_count != 0 {
6965        losses.push(LossNote {
6966            code: LossCode::FeatureHistoryRetained,
6967            category: LossCategory::DesignIntent,
6968            severity: Severity::Warning,
6969            message: format!(
6970                "Neutral semantics remain unresolved for {native_sketch_entity_count} NX sketch \
6971                 geometry record(s) and {native_sketch_constraint_count} sketch constraint \
6972                 record(s)."
6973            ),
6974            provenance: None,
6975        });
6976    }
6977}
6978
6979pub(crate) fn body_output_feature_family(definition: &FeatureDefinition) -> Option<&'static str> {
6980    match definition {
6981        FeatureDefinition::Block { .. } => Some("block"),
6982        FeatureDefinition::ExtractBody { .. } => Some("extract body"),
6983        FeatureDefinition::Loft { .. } => Some("loft"),
6984        FeatureDefinition::TrimSurface { .. } => Some("trim surface"),
6985        FeatureDefinition::ExtendSurface { .. } => Some("extend surface"),
6986        FeatureDefinition::Hole { .. } => Some("hole"),
6987        FeatureDefinition::Rib { .. } => Some("rib"),
6988        FeatureDefinition::Chamfer { .. } => Some("chamfer"),
6989        FeatureDefinition::Fillet { .. } => Some("fillet"),
6990        FeatureDefinition::FaceBlend { .. } => Some("face blend"),
6991        FeatureDefinition::SewBodies { .. } => Some("sew bodies"),
6992        FeatureDefinition::TrimBodies { .. } => Some("trim bodies"),
6993        FeatureDefinition::Extrude { .. } => Some("extrude"),
6994        FeatureDefinition::OffsetSurface { .. } => Some("offset surface"),
6995        FeatureDefinition::Thicken { .. } => Some("thicken"),
6996        FeatureDefinition::Draft { .. } => Some("draft"),
6997        FeatureDefinition::Pattern { .. } => Some("pattern"),
6998        FeatureDefinition::Combine { .. } => Some("body combine"),
6999        _ => None,
7000    }
7001}
7002
7003pub(crate) fn incomplete_expression_parameters(ir: &CadIr) -> BTreeSet<ParameterId> {
7004    let parameter_owners = ir
7005        .model
7006        .parameters
7007        .iter()
7008        .map(|parameter| parameter.owner.clone())
7009        .collect::<BTreeSet<_>>();
7010    let mut incomplete = BTreeSet::new();
7011    for owner in parameter_owners {
7012        let parameters = ir
7013            .model
7014            .parameters
7015            .iter()
7016            .filter(|parameter| parameter.owner == owner)
7017            .collect::<Vec<_>>();
7018        let mut ids_by_name = BTreeMap::<&str, Vec<&ParameterId>>::new();
7019        for parameter in &parameters {
7020            ids_by_name
7021                .entry(parameter.name.as_str())
7022                .or_default()
7023                .push(&parameter.id);
7024        }
7025        let expected = parameters
7026            .iter()
7027            .map(|parameter| {
7028                let [_] = ids_by_name.get(parameter.name.as_str())?.as_slice() else {
7029                    return None;
7030                };
7031                let mut seen = BTreeSet::new();
7032                let dependencies = crate::native::expression_parameter_names(&parameter.expression)
7033                    .into_iter()
7034                    .map(|name| {
7035                        let [dependency] = ids_by_name.get(name)?.as_slice() else {
7036                            return None;
7037                        };
7038                        Some((*dependency).clone())
7039                    })
7040                    .collect::<Option<Vec<_>>>()?;
7041                Some(
7042                    dependencies
7043                        .into_iter()
7044                        .filter(|dependency| seen.insert(dependency.clone()))
7045                        .collect::<Vec<_>>(),
7046                )
7047            })
7048            .collect::<Vec<_>>();
7049        let indices = parameters
7050            .iter()
7051            .enumerate()
7052            .map(|(index, parameter)| (&parameter.id, index))
7053            .collect::<BTreeMap<_, _>>();
7054        let mut emitted = BTreeSet::new();
7055        while let Some(index) = (0..parameters.len()).find(|index| {
7056            !emitted.contains(index)
7057                && expected[*index].as_ref().is_some_and(|dependencies| {
7058                    dependencies.iter().all(|dependency| {
7059                        indices
7060                            .get(dependency)
7061                            .is_some_and(|dependency| emitted.contains(dependency))
7062                    })
7063                })
7064        }) {
7065            emitted.insert(index);
7066        }
7067        for (index, parameter) in parameters.into_iter().enumerate() {
7068            if expected[index].as_ref() != Some(&parameter.dependencies)
7069                || !emitted.contains(&index)
7070                || parameter.value.is_none()
7071            {
7072                incomplete.insert(parameter.id.clone());
7073            }
7074        }
7075    }
7076    incomplete
7077}
7078
7079pub(crate) fn hole_feature_is_incomplete(
7080    profile: Option<&ProfileRef>,
7081    face: Option<&FaceSelection>,
7082    position: Option<Point3>,
7083    direction: Option<Vector3>,
7084    treatments: (&HoleKind, Option<&HoleKind>),
7085    diameter: Option<Length>,
7086    extent: Option<&Termination>,
7087) -> bool {
7088    let (kind, exit_kind) = treatments;
7089    let profile_incomplete = profile.is_some_and(profile_ref_is_incomplete);
7090    let face_incomplete = face.is_some_and(face_selection_is_incomplete);
7091    let location_unresolved = position.is_none() && profile.is_none_or(profile_ref_is_incomplete);
7092    let orientation_unresolved =
7093        direction.is_none() && face.is_none_or(face_selection_is_incomplete);
7094    profile_incomplete
7095        || face_incomplete
7096        || location_unresolved
7097        || orientation_unresolved
7098        || matches!(kind, HoleKind::Unresolved { .. })
7099        || exit_kind.is_some_and(|kind| matches!(kind, HoleKind::Unresolved { .. }))
7100        || diameter.is_none()
7101        || extent.is_none_or(termination_is_incomplete)
7102}
7103
7104pub(crate) fn extrude_extent_is_incomplete(extent: &ExtrudeExtent) -> bool {
7105    match extent {
7106        ExtrudeExtent::OneSided { side } | ExtrudeExtent::Symmetric { side } => {
7107            termination_is_incomplete(&side.termination)
7108        }
7109        ExtrudeExtent::TwoSided { first, second } => {
7110            termination_is_incomplete(&first.termination)
7111                || termination_is_incomplete(&second.termination)
7112        }
7113    }
7114}
7115
7116pub(crate) fn termination_is_incomplete(termination: &Termination) -> bool {
7117    match termination {
7118        Termination::Unresolved => true,
7119        Termination::ToFace { face, .. } => face_selection_is_incomplete(face),
7120        Termination::ToShape { target } => face_selection_is_incomplete(target),
7121        Termination::Blind { .. }
7122        | Termination::ThroughAll
7123        | Termination::ThroughNext
7124        | Termination::ToFirst
7125        | Termination::ToLast
7126        | Termination::ToVertex { .. }
7127        | Termination::OffsetFromFace { .. }
7128        | Termination::Angle { .. } => false,
7129    }
7130}
7131
7132pub(crate) fn rib_feature_is_incomplete(construction: &RibConstruction, op: BooleanOp) -> bool {
7133    construction
7134        .profile
7135        .as_ref()
7136        .is_none_or(profile_ref_is_incomplete)
7137        || construction.direction.is_none()
7138        || construction.thickness.is_none()
7139        || construction.side.is_none()
7140        || matches!(construction.draft, RibDraft::Unresolved)
7141        || matches!(op, BooleanOp::Unresolved)
7142}
7143
7144pub(crate) fn pattern_is_incomplete(pattern: &PatternKind) -> bool {
7145    match pattern {
7146        PatternKind::Unresolved { .. } => true,
7147        PatternKind::Linear { direction, .. } => direction.is_none(),
7148        PatternKind::LinearOffsets { direction, offsets } => {
7149            direction.is_none() || offsets.is_empty()
7150        }
7151        PatternKind::Circular { .. } | PatternKind::Mirror { .. } => false,
7152        PatternKind::CircularAngles { angles, .. } => angles.is_empty(),
7153        PatternKind::CurveDriven { path, .. } => path.as_ref().is_none_or(path_ref_is_incomplete),
7154        PatternKind::Scale { center, .. } => {
7155            matches!(center, cadmpeg_ir::features::PatternScaleCenter::Native(_))
7156        }
7157        PatternKind::Composite { stages } => {
7158            stages.is_empty()
7159                || stages
7160                    .iter()
7161                    .any(|stage| pattern_is_incomplete(&stage.pattern))
7162        }
7163    }
7164}
7165
7166pub(crate) fn pattern_feature_is_incomplete(
7167    seeds: &[cadmpeg_ir::features::PatternSeed],
7168    pattern: &PatternKind,
7169) -> bool {
7170    seeds.is_empty()
7171        || seeds
7172            .iter()
7173            .enumerate()
7174            .any(|(index, seed)| seeds[..index].contains(seed))
7175        || pattern_is_incomplete(pattern)
7176}
7177
7178pub(crate) fn radius_spec_is_incomplete(radius: &RadiusSpec) -> bool {
7179    match radius {
7180        RadiusSpec::Unresolved { .. } => true,
7181        RadiusSpec::Constant { .. } => false,
7182        RadiusSpec::Chordal { .. } => false,
7183        RadiusSpec::Variable { points } => points.len() < 2,
7184    }
7185}
7186
7187pub(crate) fn body_selection_is_incomplete(selection: &BodySelection) -> bool {
7188    explicit_body_ids(selection).is_none_or(selection_ids_are_incomplete)
7189}
7190
7191pub(crate) fn body_selections_overlap(first: &BodySelection, second: &BodySelection) -> bool {
7192    explicit_body_ids(first).is_some_and(|first| {
7193        explicit_body_ids(second)
7194            .is_some_and(|second| first.iter().any(|body| second.contains(body)))
7195    })
7196}
7197
7198fn explicit_body_ids(selection: &BodySelection) -> Option<&[BodyId]> {
7199    match selection {
7200        BodySelection::Bodies(bodies) | BodySelection::Resolved { bodies, .. } => Some(bodies),
7201        BodySelection::Unresolved
7202        | BodySelection::Historical { .. }
7203        | BodySelection::Generated { .. }
7204        | BodySelection::Local { .. }
7205        | BodySelection::Native(_) => None,
7206    }
7207}
7208
7209pub(crate) fn face_selection_is_incomplete(selection: &FaceSelection) -> bool {
7210    match selection {
7211        FaceSelection::Unresolved
7212        | FaceSelection::Generated { .. }
7213        | FaceSelection::Native(_)
7214        | FaceSelection::Historical { .. }
7215        | FaceSelection::HistoricalPartial { .. } => true,
7216        FaceSelection::Faces(faces) | FaceSelection::Resolved { faces, .. } => {
7217            selection_ids_are_incomplete(faces)
7218        }
7219    }
7220}
7221
7222pub(crate) fn face_selections_overlap(first: &FaceSelection, second: &FaceSelection) -> bool {
7223    let first = match first {
7224        FaceSelection::Faces(faces) | FaceSelection::Resolved { faces, .. } => faces,
7225        FaceSelection::Unresolved
7226        | FaceSelection::Generated { .. }
7227        | FaceSelection::Native(_)
7228        | FaceSelection::Historical { .. }
7229        | FaceSelection::HistoricalPartial { .. } => return false,
7230    };
7231    let second = match second {
7232        FaceSelection::Faces(faces) | FaceSelection::Resolved { faces, .. } => faces,
7233        FaceSelection::Unresolved
7234        | FaceSelection::Generated { .. }
7235        | FaceSelection::Native(_)
7236        | FaceSelection::Historical { .. }
7237        | FaceSelection::HistoricalPartial { .. } => return false,
7238    };
7239    first.iter().any(|face| second.contains(face))
7240}
7241
7242pub(crate) fn edge_selection_is_incomplete(selection: &EdgeSelection) -> bool {
7243    match selection {
7244        EdgeSelection::Unresolved
7245        | EdgeSelection::Generated { .. }
7246        | EdgeSelection::Native(_)
7247        | EdgeSelection::Historical { .. }
7248        | EdgeSelection::HistoricalPartial { .. } => true,
7249        EdgeSelection::All => false,
7250        EdgeSelection::Edges(edges) | EdgeSelection::Resolved { edges, .. } => {
7251            selection_ids_are_incomplete(edges)
7252        }
7253    }
7254}
7255
7256pub(crate) fn profile_ref_is_incomplete(profile: &ProfileRef) -> bool {
7257    match profile {
7258        ProfileRef::Unresolved(_) | ProfileRef::Native(_) => true,
7259        ProfileRef::Sketch(_) => false,
7260        ProfileRef::Feature(_) | ProfileRef::Generated { .. } => false,
7261        ProfileRef::Faces(faces) => selection_ids_are_incomplete(faces),
7262        ProfileRef::SketchProfiles { .. }
7263        | ProfileRef::SketchRegions { .. }
7264        | ProfileRef::SketchSelection { .. }
7265        | ProfileRef::SpatialSketchProfiles { .. }
7266        | ProfileRef::SpatialSketchSelection { .. }
7267        | ProfileRef::HistoricalFaces { .. } => false,
7268    }
7269}
7270
7271fn selection_ids_are_incomplete<T: Ord>(ids: &[T]) -> bool {
7272    ids.is_empty() || ids.iter().collect::<BTreeSet<_>>().len() != ids.len()
7273}
7274
7275pub(crate) fn path_ref_is_incomplete(path: &PathRef) -> bool {
7276    match path {
7277        PathRef::Unresolved(_) | PathRef::Native(_) => true,
7278        PathRef::HistoricalEdges { edges, .. } => selection_ids_are_incomplete(edges),
7279        PathRef::Sketch(_) => false,
7280        PathRef::SpatialSketchSelection { selections, .. } => {
7281            selection_ids_are_incomplete(selections)
7282        }
7283        PathRef::Edges(edges) => selection_ids_are_incomplete(edges),
7284        PathRef::Curves(curves) => selection_ids_are_incomplete(curves),
7285    }
7286}
7287
7288fn build_metadata_ir(
7289    scan: &Scan,
7290) -> Result<(CadIr, cadmpeg_ir::Annotations, Vec<UnknownRecord>), CodecError> {
7291    let mut ir = CadIr::empty(Units::default());
7292    let mut annotations = AnnotationBuilder::new();
7293    let mut unknowns = Vec::new();
7294    ir.source = Some(source_meta(scan));
7295    for (si, stream) in scan.streams.iter().enumerate() {
7296        if stream.kind.is_parasolid() {
7297            let unknown = unknown_stream(si, stream);
7298            let source_stream = annotations.stream("nx:container");
7299            annotations
7300                .note(&unknown.id, source_stream, stream.file_offset as u64)
7301                .tag(stream.kind.label());
7302            annotations.exactness(&unknown.id, Exactness::Derived);
7303            unknowns.push(unknown);
7304        }
7305    }
7306    let parsed = crate::native::ParsedStreams::parse(scan);
7307    let model = crate::native::NativeModel::extract(&scan.container, &scan.streams, &parsed);
7308    crate::native::attach_annotations(&mut ir, &model, scan, &mut annotations, &mut unknowns)
7309        .map_err(|error| CodecError::Malformed(error.to_string()))?;
7310    Ok((ir, annotations.build(), unknowns))
7311}
7312
7313fn build_container_report(scan: &Scan, container_only: bool) -> DecodeReport {
7314    let mut losses = Vec::new();
7315
7316    let assembly = scan
7317        .container
7318        .entries
7319        .iter()
7320        .any(|e| e.name.contains("ExternalReferences"))
7321        && !scan.has_parasolid();
7322
7323    if assembly {
7324        losses.push(LossNote {
7325            code: LossCode::AssemblyComponentsExternal,
7326            category: LossCategory::Geometry,
7327            severity: Severity::Blocking,
7328            message: "No inline Parasolid geometry: this is an assembly .prt. Component geometry \
7329                      lives in external child .prt files named in EXTREFSTREAM, and the assembled \
7330                      solid's inputs (child partitions + constraint solve) are absent from this \
7331                      file. This is an external-dependency boundary, not a decode gap."
7332                .to_string(),
7333            provenance: None,
7334        });
7335    } else {
7336        losses.push(LossNote {
7337            code: LossCode::GeometryNotTransferred,
7338            category: LossCategory::Geometry,
7339            severity: Severity::Blocking,
7340            message: "No B-rep geometry was transferred: no gate-passing analytic carrier was found \
7341                      in the embedded Parasolid streams (they may hold only B-spline/procedural \
7342                      geometry this codec does not yet type). The streams are preserved verbatim as \
7343                      unknown passthrough records."
7344                .to_string(),
7345            provenance: None,
7346        });
7347    }
7348
7349    if container_only {
7350        losses.push(LossNote {
7351            code: LossCode::ContainerOnly,
7352            category: LossCategory::Geometry,
7353            severity: Severity::Info,
7354            message: "Container-only decode requested; entity decode was not attempted."
7355                .to_string(),
7356            provenance: None,
7357        });
7358    }
7359
7360    DecodeReport {
7361        format: "nx".to_string(),
7362        container_only,
7363        geometry_transferred: false,
7364        coverage: std::collections::BTreeMap::new(),
7365        losses,
7366        notes: summary_notes(scan),
7367    }
7368}
7369
7370/// Build container and embedded-stream notes for inspection and decode reports.
7371pub fn summary_notes(scan: &Scan) -> Vec<String> {
7372    let c = &scan.container;
7373    let mut notes = vec![format!(
7374        "SPLMSSTR container: version {:#04x}, file tag {}, footer offset {}, {} directory entry/ies",
7375        c.version,
7376        c.file_tag,
7377        c.footer_offset,
7378        c.entries.len()
7379    )];
7380    notes.push(format!(
7381        "embedded streams: {} partition, {} deltas, {} plain (cached body), {} preview/non-Parasolid",
7382        scan.count(StreamKind::Partition),
7383        scan.count(StreamKind::Deltas),
7384        scan.count(StreamKind::Plain),
7385        scan.count(StreamKind::Preview),
7386    ));
7387    if let Some(schema) = scan.streams.iter().find_map(|s| s.schema.as_deref()) {
7388        notes.push(format!("Parasolid schema: {schema}"));
7389    }
7390    let framed_om_sections = c.om_sections();
7391    if !framed_om_sections.is_empty() {
7392        let declarations = framed_om_sections
7393            .iter()
7394            .map(|(_, section)| section.types.len())
7395            .sum::<usize>();
7396        let fields = framed_om_sections
7397            .iter()
7398            .map(|(_, section)| section.fields.len())
7399            .sum::<usize>();
7400        notes.push(format!(
7401            "NX object model: {} size-framed section(s), {} class declaration(s), {} field declaration(s)",
7402            framed_om_sections.len(),
7403            declarations,
7404            fields
7405        ));
7406    }
7407    let om_sections = c.indexed_om_sections();
7408    if !om_sections.is_empty() {
7409        let entities = om_sections
7410            .iter()
7411            .filter(|(_, section)| {
7412                section
7413                    .records
7414                    .first()
7415                    .is_some_and(|record| record.object_id.is_some())
7416            })
7417            .map(|(_, section)| section.records.len())
7418            .sum::<usize>();
7419        let blocks = om_sections
7420            .iter()
7421            .filter(|(_, section)| {
7422                section
7423                    .records
7424                    .first()
7425                    .is_some_and(|record| record.object_id.is_none())
7426            })
7427            .map(|(_, section)| section.records.len() + usize::from(section.control.is_some()))
7428            .sum::<usize>();
7429        if blocks == 0 {
7430            notes.push(format!(
7431                "NX object model: {} indexed section(s), {} bounded entity record(s)",
7432                om_sections.len(),
7433                entities
7434            ));
7435        } else {
7436            notes.push(format!(
7437                "NX object model: {} indexed section(s), {} ID-bounded entity record(s), {} offset-only data block(s)",
7438                om_sections.len(),
7439                entities,
7440                blocks
7441            ));
7442        }
7443    }
7444    if !scan.has_parasolid()
7445        && c.entries
7446            .iter()
7447            .any(|e| e.name.contains("ExternalReferences"))
7448    {
7449        notes.push(
7450            "no inline Parasolid geometry (assembly .prt: geometry in external child parts)"
7451                .to_string(),
7452        );
7453    }
7454    notes
7455}