Skip to main content

brep_kernel/abi/
display.rs

1use super::*;
2
3/// Everything the native renderer needs to display one resident solid.
4pub struct DisplaySolidPayload {
5    /// Faces in shell/face order — the index into this Vec IS the watertight
6    /// mesh's `face_ids` value for that face's triangles.
7    pub faces: Vec<(u64, Option<String>)>,
8    /// Watertight display mesh (chord-tolerance driven).
9    pub mesh: Mesh,
10    /// Non-degenerate edges `(edge_id, name, polyline)`, sorted by edge id.
11    pub edges: Vec<(u64, Option<String>, Vec<Vec3>)>,
12    /// Topology vertices `(vertex_id, point)`.
13    pub vertices: Vec<(u64, Vec3)>,
14    /// The chord tolerance actually used.
15    pub chord_tolerance: f64,
16}
17
18/// The app's display chord tolerance (`BetterSolid._kernelTessellationOptions`):
19/// vertex |coord| extent — falling back to the control-point hull / sqrt(2) for
20/// vertex-free solids (full spheres/tori) — times 1.5e-3, times the render-LOD
21/// factor (1.0 = the app's "Normal" preset).
22pub fn display_chord_tolerance(solid: &BrepSolid, lod_factor: f64) -> f64 {
23    let mut extent = 0.0f64;
24    for vertex in &solid.vertices {
25        extent = extent
26            .max(vertex.point.x.abs())
27            .max(vertex.point.y.abs())
28            .max(vertex.point.z.abs());
29    }
30    if extent <= 0.0 {
31        let mut control_point_extent = 0.0f64;
32        for shell in &solid.shells {
33            for face in &shell.faces {
34                for row in &face.surface.control_points {
35                    for cp in row {
36                        let w = if cp.w != 0.0 { cp.w } else { 1.0 };
37                        control_point_extent = control_point_extent
38                            .max((cp.x / w).abs())
39                            .max((cp.y / w).abs())
40                            .max((cp.z / w).abs());
41                    }
42                }
43            }
44        }
45        extent = control_point_extent / std::f64::consts::SQRT_2;
46    }
47    extent.max(1e-9) * 1.5e-3 * lod_factor
48}
49
50/// Native display payload for a resident solid: watertight mesh + face list (in
51/// mesh `face_ids` order) + edge polylines + vertices, all in one registry
52/// borrow. `lod_factor` scales the per-solid display chord tolerance (1.0 = the
53/// app's "Normal" preset; higher = coarser mesh). Callers pass a sanitized,
54/// finite, positive value — [`display_chord_tolerance`] multiplies it in directly.
55pub fn display_payload_handle_native(
56    handle: u32,
57    lod_factor: f64,
58) -> Result<DisplaySolidPayload, String> {
59    with_registered_solid_str(handle, |solid| display_payload_of_solid(solid, lod_factor))
60}
61
62/// [`display_payload_handle_native`] for many resident solids at once, in
63/// `handles` order.
64///
65/// **Instances are meshed once.** Solids that are rigid copies of one another
66/// (an imported assembly places each part many times: 857 instances of 174
67/// parts) share one tessellation: the copy with the finest display chord is
68/// meshed, and each other copy gets that mesh and its edge polylines carried
69/// by the rigid map between them, with its OWN face/edge ids and names and its
70/// own vertices, so picking, selection and per-instance colour see the solid
71/// they always saw. A copy is only drawn this way when everything but point
72/// positions is identical (ids, senses, knots, weights, pcurves) and one rigid
73/// map carries every vertex and control point of the meshed copy onto its own
74/// (`congruent_pose`); anything else, and every solid with no copy, gets the
75/// single call's payload exactly.
76///
77/// Under the `parallel` feature the meshed solids are copied out of this
78/// thread's registry (it is thread-local) a chunk at a time and meshed across
79/// rayon's pool. Display meshing opens no thread-local mode (the soundness
80/// scan's mode is closed on every worker), so a payload does not depend on
81/// the thread that computed it.
82pub fn display_payloads_handles_native(
83    handles: &[u32],
84    lod_factor: f64,
85) -> Vec<Result<DisplaySolidPayload, String>> {
86    // Group by everything a rigid move leaves alone; the first, finest-chord
87    // member of a group is meshed, the rest are posed when the map verifies.
88    let keys: Vec<Option<(u64, f64)>> = handles
89        .iter()
90        .map(|&handle| {
91            with_registered_solid_str(handle, |solid| {
92                Ok((congruence_signature(solid), display_chord_tolerance(solid, lod_factor)))
93            })
94            .ok()
95        })
96        .collect();
97    let mut base_of_signature: std::collections::HashMap<u64, usize> = std::collections::HashMap::new();
98    for (index, key) in keys.iter().enumerate() {
99        let Some((signature, chord)) = *key else { continue };
100        let base = base_of_signature.entry(signature).or_insert(index);
101        if chord < keys[*base].map_or(f64::INFINITY, |(_, base_chord)| base_chord) {
102            *base = index;
103        }
104    }
105    let mut posed: Vec<Option<(usize, RigidPose)>> = vec![None; handles.len()];
106    for (index, key) in keys.iter().enumerate() {
107        let Some((signature, _)) = *key else { continue };
108        let base = base_of_signature[&signature];
109        if base == index {
110            continue;
111        }
112        posed[index] = with_registered_solid_str(handles[base], |base_solid| {
113            with_registered_solid_str(handles[index], |solid| Ok(congruent_pose(base_solid, solid)))
114        })
115        .ok()
116        .flatten()
117        .map(|pose| (base, pose));
118    }
119    // Mesh every solid that is not posed (bases, unmatched copies, failures).
120    let meshed_indices: Vec<usize> = (0..handles.len()).filter(|&index| posed[index].is_none()).collect();
121    let meshed_handles: Vec<u32> = meshed_indices.iter().map(|&index| handles[index]).collect();
122    // BREP_PROFILE: how much of the batch was meshed and how much posed.
123    let profile_started = crate::profile::profile_enabled().then(web_time::Instant::now);
124    let mut payloads: Vec<Option<Result<DisplaySolidPayload, String>>> = (0..handles.len()).map(|_| None).collect();
125    for (index, payload) in meshed_indices.iter().zip(mesh_display_payloads(&meshed_handles, lod_factor)) {
126        payloads[*index] = Some(payload);
127    }
128    let mesh_ms = profile_started.map(|started| started.elapsed().as_secs_f64() * 1_000.0);
129    // Pose the copies; a copy whose base failed to mesh is meshed on its own.
130    for index in 0..handles.len() {
131        let Some((base, pose)) = posed[index].take() else { continue };
132        let result = match &payloads[base] {
133            Some(Ok(base_payload)) => {
134                with_registered_solid_str(handles[index], |solid| Ok(posed_payload(base_payload, &pose, solid)))
135            }
136            _ => display_payload_handle_native(handles[index], lod_factor),
137        };
138        payloads[index] = Some(result);
139    }
140    if let Some(started) = profile_started {
141        profile_line!(
142            "display.batch solids={} meshed={} posed={} mesh_ms={:.1} total_ms={:.1}",
143            handles.len(),
144            meshed_indices.len(),
145            handles.len() - meshed_indices.len(),
146            mesh_ms.unwrap_or(0.0),
147            started.elapsed().as_secs_f64() * 1_000.0
148        );
149    }
150    payloads.into_iter().map(|payload| payload.expect("every slot meshed or posed")).collect()
151}
152
153/// Mesh each handle's solid on its own (across the pool under `parallel`).
154fn mesh_display_payloads(handles: &[u32], lod_factor: f64) -> Vec<Result<DisplaySolidPayload, String>> {
155    #[cfg(feature = "parallel")]
156    {
157        use rayon::prelude::*;
158        // Bounded copies: a chunk of solids is resident twice at most.
159        const CHUNK: usize = 256;
160        let mut payloads = Vec::with_capacity(handles.len());
161        for chunk in handles.chunks(CHUNK) {
162            let solids: Vec<Result<BrepSolid, String>> =
163                chunk.iter().map(|&handle| registered_solid_clone(handle)).collect();
164            payloads.extend(
165                solids
166                    .into_par_iter()
167                    .with_max_len(1)
168                    .map(|solid| solid.and_then(|solid| display_payload_of_solid(&solid, lod_factor)))
169                    .collect::<Vec<_>>(),
170            );
171        }
172        payloads
173    }
174    #[cfg(not(feature = "parallel"))]
175    {
176        handles.iter().map(|&handle| display_payload_handle_native(handle, lod_factor)).collect()
177    }
178}
179
180/// A proper rigid map `p -> rotation * p + translation` (rows of `rotation`).
181#[derive(Clone, Copy, Debug)]
182struct RigidPose {
183    rotation: [[f64; 3]; 3],
184    translation: [f64; 3],
185}
186
187impl RigidPose {
188    fn point(&self, p: Vec3) -> Vec3 {
189        let r = &self.rotation;
190        Vec3::new(
191            r[0][0] * p.x + r[0][1] * p.y + r[0][2] * p.z + self.translation[0],
192            r[1][0] * p.x + r[1][1] * p.y + r[1][2] * p.z + self.translation[1],
193            r[2][0] * p.x + r[2][1] * p.y + r[2][2] * p.z + self.translation[2],
194        )
195    }
196
197    fn direction(&self, d: Vec3) -> Vec3 {
198        let r = &self.rotation;
199        Vec3::new(
200            r[0][0] * d.x + r[0][1] * d.y + r[0][2] * d.z,
201            r[1][0] * d.x + r[1][1] * d.y + r[1][2] * d.z,
202            r[2][0] * d.x + r[2][1] * d.y + r[2][2] * d.z,
203        )
204    }
205}
206
207/// A hash of everything a rigid move leaves unchanged: ids, topology, senses,
208/// degrees, knots, weights, parameter ranges and pcurves. Names are left out
209/// (instances are named apart). Equal signatures only nominate a pair;
210/// [`congruent_pose`] decides.
211fn congruence_signature(solid: &BrepSolid) -> u64 {
212    use std::hash::{Hash, Hasher};
213    let mut h = rustc_hash::FxHasher::default();
214    std::mem::discriminant(&solid.kind).hash(&mut h);
215    solid.genus.hash(&mut h);
216    solid.cells.len().hash(&mut h);
217    solid.vertices.len().hash(&mut h);
218    for vertex in &solid.vertices {
219        vertex.id.hash(&mut h);
220    }
221    solid.edges.len().hash(&mut h);
222    for edge in &solid.edges {
223        (edge.id, edge.start_vertex_id, edge.end_vertex_id, edge.degenerate).hash(&mut h);
224        (edge.t0.to_bits(), edge.t1.to_bits(), edge.curve.degree).hash(&mut h);
225        edge.curve.knots.iter().for_each(|k| k.to_bits().hash(&mut h));
226        edge.curve.control_points.iter().for_each(|c| c.w.to_bits().hash(&mut h));
227    }
228    solid.shells.len().hash(&mut h);
229    for shell in &solid.shells {
230        (shell.id, shell.faces.len()).hash(&mut h);
231        for face in &shell.faces {
232            let surface = &face.surface;
233            (face.id, face.same_sense, surface.degree_u, surface.degree_v, face.loops.len()).hash(&mut h);
234            surface.knots_u.iter().chain(&surface.knots_v).for_each(|k| k.to_bits().hash(&mut h));
235            surface.control_points.len().hash(&mut h);
236            for row in &surface.control_points {
237                row.len().hash(&mut h);
238                row.iter().for_each(|c| c.w.to_bits().hash(&mut h));
239            }
240            for loop_record in &face.loops {
241                (loop_record.id, loop_record.coedges.len()).hash(&mut h);
242                for coedge in &loop_record.coedges {
243                    (coedge.id, coedge.edge_id, coedge.forward, coedge.pcurve.degree).hash(&mut h);
244                    coedge.pcurve.knots.iter().for_each(|k| k.to_bits().hash(&mut h));
245                    for c in &coedge.pcurve.control_points {
246                        [c.x, c.y, c.z, c.w].iter().for_each(|v| v.to_bits().hash(&mut h));
247                    }
248                }
249            }
250        }
251    }
252    h.finish()
253}
254
255/// The rigid map taking `base` onto `solid`, when `solid` is `base` moved
256/// rigidly: identical in everything but point positions, and the map carries
257/// every vertex and every edge-curve and surface control point of `base` to
258/// `solid`'s within `1e-9` of the larger coordinate magnitude. `None`
259/// otherwise (a scaled, mirrored or reshaped copy, or no three points that
260/// fix a frame).
261fn congruent_pose(base: &BrepSolid, solid: &BrepSolid) -> Option<RigidPose> {
262    if !same_but_positions(base, solid) {
263        return None;
264    }
265    let points = |s: &BrepSolid| -> Option<Vec<(Vec3, f64)>> {
266        let mut out: Vec<(Vec3, f64)> = s.vertices.iter().map(|v| (v.point, 1.0)).collect();
267        for edge in &s.edges {
268            for c in &edge.curve.control_points {
269                out.push((c.point().ok()?, c.w));
270            }
271        }
272        for shell in &s.shells {
273            for face in &shell.faces {
274                for row in &face.surface.control_points {
275                    for c in row {
276                        out.push((c.point().ok()?, c.w));
277                    }
278                }
279            }
280        }
281        Some(out)
282    };
283    let from = points(base)?;
284    let to = points(solid)?;
285    if from.len() != to.len() || from.len() < 3 {
286        return None;
287    }
288    let pose = frame_pose(&from, &to)?;
289    let magnitude = from
290        .iter()
291        .chain(&to)
292        .map(|(p, _)| p.x.abs().max(p.y.abs()).max(p.z.abs()))
293        .fold(1.0f64, f64::max);
294    let tolerance = 1e-9 * magnitude;
295    from.iter()
296        .zip(&to)
297        .all(|((p, wp), (q, wq))| wp.to_bits() == wq.to_bits() && pose.point(*p).sub(*q).length() <= tolerance)
298        .then_some(pose)
299}
300
301/// The proper rigid map fixed by three well-spread points of `from` and their
302/// partners in `to`: orthonormal frames on (p0, farthest from p0, farthest
303/// from that line), rotation = frame_to * frame_fromᵀ.
304fn frame_pose(from: &[(Vec3, f64)], to: &[(Vec3, f64)]) -> Option<RigidPose> {
305    let p0 = from[0].0;
306    let (i1, d1) = from
307        .iter()
308        .enumerate()
309        .map(|(i, (p, _))| (i, p.sub(p0).length()))
310        .fold((0, 0.0), |best, next| if next.1 > best.1 { next } else { best });
311    if !(d1 > 0.0) {
312        return None;
313    }
314    let axis = from[i1].0.sub(p0).scale(1.0 / d1);
315    let (i2, d2) = from
316        .iter()
317        .enumerate()
318        .map(|(i, (p, _))| {
319            let d = p.sub(p0);
320            (i, d.sub(axis.scale(d.dot(axis))).length())
321        })
322        .fold((0, 0.0), |best, next| if next.1 > best.1 { next } else { best });
323    if !(d2 > 1e-6 * d1) {
324        return None;
325    }
326    let frame = |a: Vec3, b: Vec3, c: Vec3| -> Option<[Vec3; 3]> {
327        let e1 = b.sub(a);
328        let e1 = e1.scale(1.0 / e1.length());
329        let d = c.sub(a);
330        let e2 = d.sub(e1.scale(d.dot(e1)));
331        let length = e2.length();
332        if !(length > 0.0) {
333            return None;
334        }
335        let e2 = e2.scale(1.0 / length);
336        Some([e1, e2, e1.cross(e2)])
337    };
338    let f = frame(p0, from[i1].0, from[i2].0)?;
339    let g = frame(to[0].0, to[i1].0, to[i2].0)?;
340    // rotation = Σ_k g_k f_kᵀ
341    let mut rotation = [[0.0f64; 3]; 3];
342    for k in 0..3 {
343        let (gk, fk) = ([g[k].x, g[k].y, g[k].z], [f[k].x, f[k].y, f[k].z]);
344        for row in 0..3 {
345            for col in 0..3 {
346                rotation[row][col] += gk[row] * fk[col];
347            }
348        }
349    }
350    let mut pose = RigidPose { rotation, translation: [0.0; 3] };
351    let moved = pose.point(p0);
352    let q0 = to[0].0;
353    pose.translation = [q0.x - moved.x, q0.y - moved.y, q0.z - moved.z];
354    pose.translation.iter().all(|t| t.is_finite()).then_some(pose)
355}
356
357/// Everything but point positions (and names) equal, field by field.
358fn same_but_positions(a: &BrepSolid, b: &BrepSolid) -> bool {
359    let same_bits = |x: &[f64], y: &[f64]| x.len() == y.len() && x.iter().zip(y).all(|(p, q)| p.to_bits() == q.to_bits());
360    let same_weights = |x: &[Vec4], y: &[Vec4]| x.len() == y.len() && x.iter().zip(y).all(|(p, q)| p.w.to_bits() == q.w.to_bits());
361    let same_pcurve = |x: &NurbsCurve, y: &NurbsCurve| {
362        x.degree == y.degree
363            && same_bits(&x.knots, &y.knots)
364            && x.control_points.len() == y.control_points.len()
365            && x.control_points.iter().zip(&y.control_points).all(|(p, q)| {
366                [p.x, p.y, p.z, p.w].iter().zip([q.x, q.y, q.z, q.w]).all(|(u, v)| u.to_bits() == v.to_bits())
367            })
368    };
369    a.kind == b.kind
370        && a.genus == b.genus
371        && a.cells.len() == b.cells.len()
372        && a.cells.iter().zip(&b.cells).all(|(x, y)| x.id == y.id && x.void == y.void && x.faces == y.faces)
373        && a.vertices.len() == b.vertices.len()
374        && a.vertices.iter().zip(&b.vertices).all(|(x, y)| x.id == y.id)
375        && a.edges.len() == b.edges.len()
376        && a.edges.iter().zip(&b.edges).all(|(x, y)| {
377            x.id == y.id
378                && x.start_vertex_id == y.start_vertex_id
379                && x.end_vertex_id == y.end_vertex_id
380                && x.degenerate == y.degenerate
381                && x.t0.to_bits() == y.t0.to_bits()
382                && x.t1.to_bits() == y.t1.to_bits()
383                && x.curve.degree == y.curve.degree
384                && same_bits(&x.curve.knots, &y.curve.knots)
385                && same_weights(&x.curve.control_points, &y.curve.control_points)
386        })
387        && a.shells.len() == b.shells.len()
388        && a.shells.iter().zip(&b.shells).all(|(x, y)| {
389            x.id == y.id
390                && x.faces.len() == y.faces.len()
391                && x.faces.iter().zip(&y.faces).all(|(f, g)| {
392                    f.id == g.id
393                        && f.same_sense == g.same_sense
394                        && f.surface.degree_u == g.surface.degree_u
395                        && f.surface.degree_v == g.surface.degree_v
396                        && same_bits(&f.surface.knots_u, &g.surface.knots_u)
397                        && same_bits(&f.surface.knots_v, &g.surface.knots_v)
398                        && f.surface.control_points.len() == g.surface.control_points.len()
399                        && f.surface.control_points.iter().zip(&g.surface.control_points).all(|(r, s)| same_weights(r, s))
400                        && f.loops.len() == g.loops.len()
401                        && f.loops.iter().zip(&g.loops).all(|(l, m)| {
402                            l.id == m.id
403                                && l.coedges.len() == m.coedges.len()
404                                && l.coedges.iter().zip(&m.coedges).all(|(c, d)| {
405                                    c.id == d.id && c.edge_id == d.edge_id && c.forward == d.forward && same_pcurve(&c.pcurve, &d.pcurve)
406                                })
407                        })
408                })
409        })
410}
411
412/// `base`'s payload carried by `pose`, with `solid`'s own face and edge ids
413/// and names and its own vertices (the meshed copy and `solid` share every id,
414/// [`same_but_positions`]).
415fn posed_payload(base: &DisplaySolidPayload, pose: &RigidPose, solid: &BrepSolid) -> DisplaySolidPayload {
416    let mut mesh = base.mesh.clone();
417    for p in mesh.positions.chunks_exact_mut(3) {
418        let q = pose.point(Vec3::new(p[0], p[1], p[2]));
419        p.copy_from_slice(&[q.x, q.y, q.z]);
420    }
421    for n in mesh.normals.chunks_exact_mut(3) {
422        let q = pose.direction(Vec3::new(n[0], n[1], n[2]));
423        n.copy_from_slice(&[q.x, q.y, q.z]);
424    }
425    let faces = solid
426        .shells
427        .iter()
428        .flat_map(|shell| shell.faces.iter().map(|face| (face.id, face.name.clone())))
429        .collect();
430    let edge_names: std::collections::HashMap<u64, &String> =
431        solid.edges.iter().filter_map(|edge| edge.name.as_ref().map(|name| (edge.id, name))).collect();
432    let edges = base
433        .edges
434        .iter()
435        .map(|(id, _, points)| (*id, edge_names.get(id).map(|name| (*name).clone()), points.iter().map(|p| pose.point(*p)).collect()))
436        .collect();
437    let vertices = solid.vertices.iter().map(|vertex| (vertex.id, vertex.point)).collect();
438    DisplaySolidPayload { faces, mesh, edges, vertices, chord_tolerance: base.chord_tolerance }
439}
440
441/// The display payload of one solid; see [`display_payload_handle_native`].
442fn display_payload_of_solid(solid: &BrepSolid, lod_factor: f64) -> Result<DisplaySolidPayload, String> {
443    let chord = display_chord_tolerance(solid, lod_factor);
444    let mesh = tessellate_brep_watertight(solid, chord)?;
445    let mut faces = Vec::new();
446    for shell in &solid.shells {
447        for face in &shell.faces {
448            faces.push((face.id, face.name.clone()));
449        }
450    }
451    let edge_names: std::collections::HashMap<u64, String> = solid
452        .edges
453        .iter()
454        .filter_map(|edge| edge.name.as_ref().map(|name| (edge.id, name.clone())))
455        .collect();
456    let edges = sample_edge_polylines(solid, chord)?
457        .into_iter()
458        .map(|(id, points)| (id, edge_names.get(&id).cloned(), points))
459        .collect();
460    let vertices = solid
461        .vertices
462        .iter()
463        .map(|vertex| (vertex.id, vertex.point))
464        .collect();
465    Ok(DisplaySolidPayload {
466        faces,
467        mesh,
468        edges,
469        vertices,
470        chord_tolerance: chord,
471    })
472}
473
474/// Native display payload for a solved sketch PROFILE — the SHEET-SOLID view of a
475/// committed sketch: a planar FACE mesh + its named boundary EDGES + corner
476/// VERTICES, so a sketch is pickable / selectable / measurable through the exact
477/// same display path as a real solid. NO solid is registered — the payload is
478/// synthesized directly from the profile, so it owns no scene handle (the display
479/// carries `source_handle = 0`).
480///
481/// The FACE triangulates each region (outer boundary minus holes) via the
482/// watertight planar path, mapped to world through the profile's frame; each
483/// triangle rides face id 0 (one logical sheet face). EDGES are each boundary
484/// curve sampled to a world polyline, named from the profile's `{sketchId}:G{gid}`
485/// edge names; VERTICES are the loop corners. An empty profile (no closed region)
486/// yields an empty payload (no face, no edges) — an open/underconstrained sketch
487/// has no sheet.
488pub fn sketch_profile_display_payload(
489    profile: &crate::feature_pipeline::SketchProfile,
490) -> DisplaySolidPayload {
491    /// Samples per boundary curve — enough to resolve arcs/circles; straight
492    /// segments oversample harmlessly.
493    const SEGMENTS: usize = 24;
494    let origin = profile.origin;
495    let x_axis = profile.x_axis;
496    let y_axis = profile.y_axis;
497    let normal = profile.z_axis;
498    let to_uv = |p: Vec3| {
499        let d = p.sub(origin);
500        [d.dot(x_axis), d.dot(y_axis)]
501    };
502
503    let mut mesh = Mesh::default();
504    let mut edges: Vec<(u64, Option<String>, Vec<Vec3>)> = Vec::new();
505    let mut vertices: Vec<(u64, Vec3)> = Vec::new();
506    let mut next_edge_id: u64 = 0;
507    let mut next_vertex_id: u64 = 0;
508    let mut sketch_id: Option<String> = None;
509
510    for region in &profile.regions {
511        let Some((outer, holes)) = region.split_first() else {
512            continue;
513        };
514        // Boundary vertices (uv, world) for triangulation: sample [t0, t1) per
515        // curve so the next curve contributes the shared corner exactly once.
516        let boundary = |lp: &crate::feature_pipeline::ProfileLoop| -> Vec<([f64; 2], Vec3)> {
517            let mut out = Vec::new();
518            for curve in &lp.curves {
519                let Ok([t0, t1]) = curve.domain() else { continue };
520                for step in 0..SEGMENTS {
521                    let t = t0 + (t1 - t0) * step as f64 / SEGMENTS as f64;
522                    if let Ok(p) = curve.evaluate(t) {
523                        out.push((to_uv(p), p));
524                    }
525                }
526            }
527            out
528        };
529        let outer_b = boundary(outer);
530        let holes_b: Vec<Vec<([f64; 2], Vec3)>> = holes.iter().map(boundary).collect();
531        for [a, b, c] in watertight_tessellation::triangulate_planar_region(&outer_b, &holes_b) {
532            let base = (mesh.positions.len() / 3) as u32;
533            for p in [a, b, c] {
534                mesh.positions.extend([p.x, p.y, p.z]);
535                mesh.normals.extend([normal.x, normal.y, normal.z]);
536            }
537            mesh.indices.extend([base, base + 1, base + 2]);
538            mesh.face_ids.push(0);
539        }
540
541        // Named boundary EDGES + corner VERTICES for every loop of this region.
542        for lp in region {
543            for (index, curve) in lp.curves.iter().enumerate() {
544                let Ok([t0, t1]) = curve.domain() else { continue };
545                let mut polyline = Vec::with_capacity(SEGMENTS + 1);
546                for step in 0..=SEGMENTS {
547                    let t = t0 + (t1 - t0) * step as f64 / SEGMENTS as f64;
548                    if let Ok(p) = curve.evaluate(t) {
549                        polyline.push(p);
550                    }
551                }
552                let name = lp.edge_names.get(index).cloned().flatten();
553                if sketch_id.is_none() {
554                    // `{sketchId}:G{gid}` → the sheet's face inherits `{sketchId}`.
555                    sketch_id = name
556                        .as_deref()
557                        .and_then(|n| n.split_once(":G").map(|(id, _)| id.to_string()));
558                }
559                if polyline.len() >= 2 {
560                    edges.push((next_edge_id, name, polyline));
561                    next_edge_id += 1;
562                }
563                if let Ok(p) = curve.evaluate(t0) {
564                    vertices.push((next_vertex_id, p));
565                    next_vertex_id += 1;
566                }
567            }
568        }
569    }
570
571    // One logical sheet face (id 0), only if the mesh has triangles.
572    let faces = if mesh.face_ids.is_empty() {
573        Vec::new()
574    } else {
575        let face_name = sketch_id.map(|id| format!("{id}:FACE"));
576        vec![(0u64, face_name)]
577    };
578    DisplaySolidPayload {
579        faces,
580        mesh,
581        edges,
582        vertices,
583        chord_tolerance: 0.0,
584    }
585}
586
587/// Native display payload for a whole committed SKETCH: its profile SHEET (when
588/// the sketch closes a region) PLUS every model SEGMENT the sheet does not
589/// already draw.
590///
591/// [`sketch_profile_display_payload`] draws a sketch through its closed profile,
592/// and that is the only display a committed sketch had. A sketch that closes
593/// NOTHING — one line, an open chain, a trajectory drawn alongside a closed loop —
594/// publishes no profile at all, so it was handed to the sheet builder as `None`
595/// and drew nothing: invisible in 3D, and unpickable there (the 2026-09-02 report,
596/// *"Sketch with single edge not visible in 3D"*). Its geometry was never missing,
597/// only unaccepted — the SKETCH feature already publishes one world curve per
598/// model segment under `{sketchId}:G{gid}`, which is exactly what `segments`
599/// carries here.
600///
601/// Each such segment draws as one NAMED edge (the same name a downstream
602/// `reference_selection` stores, so picking the drawn line resolves) with its
603/// endpoints as vertices. A segment whose name the sheet already drew is SKIPPED,
604/// so a closed sketch draws each boundary edge exactly once and a mixed sketch
605/// draws its loop and its open chain side by side. Endpoints are deduped against
606/// the points already drawn — consecutive segments of one chain share a junction.
607///
608/// `points` are the sketch's standalone MODEL points (world space): a sketch
609/// with points only — a hole-placement sketch — has no segment at all, so it
610/// drew nothing and was invisible and unpickable in 3D. Each point draws as a
611/// vertex, deduped against the endpoints the segments already drew, so a point
612/// that is also a segment corner draws once.
613///
614/// Like its sheet sibling this registers no solid: the payload is synthesized
615/// directly from the profile, the curves and the points.
616pub fn sketch_display_payload(
617    profile: Option<&crate::feature_pipeline::SketchProfile>,
618    segments: &[(String, Vec<NurbsCurve>)],
619    points: &[Vec3],
620) -> DisplaySolidPayload {
621    /// Samples per segment for a simple curve — the sheet builder's own
622    /// resolution. A curve with many knot spans (a fitted helix has ~64 per
623    /// turn) gets four samples per span instead, so a long fitted curve draws
624    /// as itself rather than as a coarse polygon; capped so a pathological
625    /// knot vector cannot flood the display.
626    const SEGMENTS: usize = 24;
627    const SAMPLES_PER_SPAN: usize = 4;
628    const MAX_SEGMENTS: usize = 4096;
629    /// Two drawn endpoints closer than this are the same corner (a chain's
630    /// junction points are the SAME solved sketch point, so they agree exactly).
631    const SAME_POINT: f64 = 1e-9;
632
633    let mut payload = match profile {
634        Some(profile) => sketch_profile_display_payload(profile),
635        None => DisplaySolidPayload {
636            faces: Vec::new(),
637            mesh: Mesh::default(),
638            edges: Vec::new(),
639            vertices: Vec::new(),
640            chord_tolerance: 0.0,
641        },
642    };
643
644    let drawn: std::collections::HashSet<String> = payload
645        .edges
646        .iter()
647        .filter_map(|(_, name, _)| name.clone())
648        .collect();
649    let mut next_edge_id = payload
650        .edges
651        .iter()
652        .map(|(id, _, _)| id + 1)
653        .max()
654        .unwrap_or(0);
655    let mut next_vertex_id = payload.vertices.iter().map(|(id, _)| id + 1).max().unwrap_or(0);
656
657    for (name, curves) in segments {
658        if drawn.contains(name) {
659            continue;
660        }
661        for curve in curves {
662            let Ok([t0, t1]) = curve.domain() else { continue };
663            let spans = curve
664                .knots
665                .windows(2)
666                .filter(|pair| pair[1] > pair[0])
667                .count();
668            let segments = (spans * SAMPLES_PER_SPAN).clamp(SEGMENTS, MAX_SEGMENTS);
669            let mut polyline = Vec::with_capacity(segments + 1);
670            for step in 0..=segments {
671                let t = t0 + (t1 - t0) * step as f64 / segments as f64;
672                if let Ok(point) = curve.evaluate(t) {
673                    polyline.push(point);
674                }
675            }
676            if polyline.len() < 2 {
677                continue;
678            }
679            for end in [polyline[0], polyline[polyline.len() - 1]] {
680                if payload
681                    .vertices
682                    .iter()
683                    .any(|(_, point)| point.sub(end).length() <= SAME_POINT)
684                {
685                    continue;
686                }
687                payload.vertices.push((next_vertex_id, end));
688                next_vertex_id += 1;
689            }
690            payload.edges.push((next_edge_id, Some(name.clone()), polyline));
691            next_edge_id += 1;
692        }
693    }
694
695    // Standalone points: one vertex each, skipping any position a segment
696    // endpoint (or an earlier point) already drew.
697    for &point in points {
698        if payload
699            .vertices
700            .iter()
701            .any(|(_, drawn)| drawn.sub(point).length() <= SAME_POINT)
702        {
703            continue;
704        }
705        payload.vertices.push((next_vertex_id, point));
706        next_vertex_id += 1;
707    }
708    payload
709}
710
711/// Full mass properties of a resident solid, scaled to `density` (the native,
712/// non-wasm sibling of [`mass_properties_handle`] for the in-process renderer):
713/// volume + surface area + centroid + centroidal inertia tensor + principal
714/// axes/moments (Golovanov §8.11). The underlying geometry is unit-density; here
715/// `mass = density * volume` and every inertia quantity scales linearly with
716/// density (centroid + principal axes are density-independent). `density` is in
717/// mass units per mm³ (the kernel's length convention is millimetres); pass
718/// `1.0` for the raw geometric result (`mass == volume`). Reads the solid in one
719/// registry borrow; the topology never crosses a boundary.
720pub fn mass_properties_handle_native(
721    handle: u32,
722    density: f64,
723) -> Result<DensityMassProperties, String> {
724    with_registered_solid_str(handle, |solid| {
725        Ok(solid_mass_properties_full(solid)?.with_density(density))
726    })
727}
728
729/// Surface area and volume of a resident solid — the native sibling of
730/// [`mass_properties_handle_native`] for a caller that reads NOTHING but
731/// those two numbers (`examples/case_replay.rs` records volume and area per
732/// solid and discards the rest). The full entry runs a second station sweep
733/// over every trimmed face for the nine moment integrands, which costs as
734/// much again as the area/volume sweep: on `anotherBooleanFail`'s imported
735/// 32-face body 2.6 s against 1.3 s, and 5.3 s of that case's 12.9 s replay
736/// went to moments nobody read. The two numbers come out of the same
737/// `solid_mass_properties` call the full entry starts with, so they are
738/// bit-identical to its `volume` and `surface_area`; the full entry's
739/// refusal of a non-positive volume is kept so a caller switching over sees
740/// the same `Err` on the same solids. Reads the solid in one registry borrow.
741pub fn mass_measures_handle_native(handle: u32) -> Result<MassProperties, String> {
742    with_registered_solid_str(handle, |solid| {
743        let _caller = crate::mass_caller("abi.mass_measures");
744        let properties = solid_mass_properties(solid)?;
745        if properties.volume.abs() <= 1e-30 {
746            return Err("solid_mass_properties_full: non-positive volume".into());
747        }
748        Ok(properties)
749    })
750}
751
752/// Topology validation issues of a resident solid as `(severity, message)`
753/// pairs — the native sibling of the JSON validators, for in-process
754/// qualification tooling (`examples/case_replay.rs`). An empty Vec means the
755/// incidence checks passed; that is NOT a correctness proof: `validate()` tests
756/// neither connectivity nor face-vs-face self-intersection, and a validating
757/// solid can still be the wrong solid. Reads the solid in one registry borrow.
758pub fn validate_handle_native(handle: u32) -> Result<Vec<(String, String)>, String> {
759    with_registered_solid_str(handle, |solid| {
760        Ok(solid
761            .validate()
762            .into_iter()
763            .map(|issue| (issue.severity.to_string(), issue.message))
764            .collect())
765    })
766}
767
768/// Topology TOTALS of a resident solid as
769/// `(faces, edges, non_degenerate_edges, vertices)`, read straight off the
770/// record: every face of every shell, every edge, every edge that is not a
771/// collapsed pole boundary, every vertex.
772///
773/// The two edge counts answer different questions and both are worth
774/// recording. The RAW total is what a reader means by "15 edges" when looking
775/// at the record, and it is the right thing to diff between two runs. The
776/// NON-DEGENERATE count is the one a derivation can state from the intended
777/// construction, because it excludes the seam and pole boundaries the kernel
778/// chose as scaffolding rather than anything the shape has.
779///
780/// These are what a case gate compares. A solid can keep its volume to the
781/// last digit while gaining or losing edges — `inbox-20260909-tube-joint-edge-splits`
782/// carried "7 faces / 17 edges / 11 vertices" in prose for a month with every
783/// local assertion in its suite passing — so the totals are recorded and
784/// diffed.
785///
786/// Since 2026-09-20 they can also be STATED. The two checks answer different
787/// questions and both are kept — a diff against the baseline says "did this
788/// change", a claim says "was it ever right" — and it is the NON-degenerate
789/// count that a claim may state, for the reason above. Reads the solid in one
790/// registry borrow.
791pub fn topology_counts_native(handle: u32) -> Result<(usize, usize, usize, usize), String> {
792    with_registered_solid_str(handle, |solid| {
793        Ok((
794            solid.shells.iter().map(|shell| shell.faces.len()).sum(),
795            solid.edges.len(),
796            solid.edges.iter().filter(|edge| !edge.degenerate).count(),
797            solid.vertices.len(),
798        ))
799    })
800}
801
802/// Connectivity of a resident solid — the check `validate()` does NOT make.
803/// Per-shell edge-connected face components, edges shared across shells, and
804/// pinch vertices. Pure topology, no geometry, no tolerance; see
805/// [`crate::solid_connectivity`] for which part is a gate and which is a
806/// diagnostic. Reads the solid in one registry borrow.
807pub fn connectivity_handle_native(handle: u32) -> Result<crate::ConnectivityReport, String> {
808    with_registered_solid_str(handle, |solid| Ok(crate::solid_connectivity(solid)))
809}
810
811/// Closedness and Euler parity of a resident solid — the THIRD check
812/// `validate()` does not make on its own. Its Euler comparison reduces, on a
813/// freshly derived solid, to exactly "is the characteristic even", and it is
814/// switched off on any solid carrying a degenerate edge; this asks the question
815/// directly and on every solid. Pure counting, one registry borrow.
816pub fn euler_handle_native(handle: u32) -> Result<crate::EulerReport, String> {
817    with_registered_solid_str(handle, |solid| Ok(crate::solid_euler(solid)))
818}
819
820/// The body-kind report of a resident solid. A report beside the Euler
821/// one, not a gate: no body carries a kind yet. Pure counting, one
822/// registry borrow.
823pub fn topology_kind_handle_native(handle: u32) -> Result<crate::KindReport, String> {
824    with_registered_solid_str(handle, |solid| Ok(crate::topology_kind(solid)))
825}
826
827/// The kind a resident solid DECLARES (`BrepSolid::kind`), as distinct
828/// from the kind the report above reads from the edge census. `Solid`
829/// for every body built today.
830pub fn body_kind_handle_native(handle: u32) -> Result<crate::BodyKind, String> {
831    with_registered_solid_str(handle, |solid| Ok(solid.kind))
832}
833
834/// Face-loop self-crossing of a resident solid — the check no OTHER detector
835/// here can make. `validate()` is silent (the incidence is perfect), the
836/// face-versus-face scan has no pair to confirm (both triangles are on one
837/// face), connectivity and Euler parity count the same cells either way; the
838/// face's AREA is the only other tell. Pure parameter-space work on the trims
839/// the face already carries — no tessellation — so it is cheap enough to be a
840/// default. Reads the solid in one registry borrow.
841pub fn loop_crossings_handle_native(handle: u32) -> Result<crate::LoopCrossingReport, String> {
842    with_registered_solid_str(handle, |solid| Ok(crate::loop_self_crossings(solid)))
843}
844
845/// The vector-area closure of a resident solid: does each closed shell's
846/// TRIMS enclose the zero vector area a closed shell owes, and if not, which
847/// faces own the residual. One pass over the trims and the edge curves — no
848/// tessellation, no projection — so unlike the crossing scan it is cheap
849/// enough for a default gate. Reads the solid in one registry borrow.
850pub fn vector_area_handle_native(handle: u32) -> Result<crate::VectorAreaReport, String> {
851    with_registered_solid_str(handle, |solid| Ok(crate::shell_vector_areas(solid)))
852}
853
854/// Face-versus-face self-intersection of a resident solid — the OTHER check
855/// `validate()` does not make (its one "self-intersects" string is a uv-wire
856/// warning). Opt-in and expensive: it tessellates the solid and walks a
857/// triangle BVH, so no default gate calls it. `chord_tolerance` overrides the
858/// per-solid display density when positive. Reads the solid in one registry
859/// borrow.
860pub fn self_intersections_handle_native(
861    handle: u32,
862    chord_tolerance: f64,
863) -> Result<crate::SelfIntersectionReport, String> {
864    with_registered_solid_str(handle, |solid| {
865        let mut options = crate::SelfIntersectionOptions::for_solid(solid);
866        if chord_tolerance > 0.0 && chord_tolerance.is_finite() {
867            options.chord_tolerance = chord_tolerance;
868        }
869        crate::solid_self_intersections(solid, options)
870    })
871}
872
873/// The Transform feature's BBOX_CENTER pivot for a resident source solid.
874/// Share the kernel's vertex-bbox definition with viewport controls; display
875/// tessellation bounds would give a different pivot for curved solids.
876pub fn transform_pivot_native(handle: u32) -> Result<[f64; 3], String> {
877    with_registered_solid_str(handle, |solid| {
878        Ok(crate::feature_pipeline::transform_bbox_center(solid))
879    })
880}
881
882/// Total 3D arc length (mm) of every non-degenerate edge of a resident solid —
883/// the Properties panel's "total edge length" measurement. Native sibling of the
884/// mass-properties accessors; one short registry borrow, topology never crosses
885/// the boundary.
886pub fn solid_edge_length_total_native(handle: u32) -> Result<f64, String> {
887    with_registered_solid_str(handle, solid_edge_length_total)
888}
889
890/// `(area, boundary_edge_total_length, surface_type)` of a resident solid's
891/// named face (mm² / mm / classification): the face's surface area, the summed
892/// arc length of its boundary edges, and a short label for its underlying
893/// carrier surface (`"Plane"`, `"Cylinder"`, `"Cone"`, `"Sphere"`, `"Torus"`,
894/// `"Surface of revolution"`, or `"NURBS"` when the exact rational patch is not
895/// a recognized analytic carrier). Errs if no face carries `face_name`.
896pub fn face_measurements_native(
897    handle: u32,
898    face_name: &str,
899) -> Result<(f64, f64, &'static str), String> {
900    with_registered_solid_str(handle, |solid| {
901        let face = solid
902            .shells
903            .iter()
904            .flat_map(|shell| &shell.faces)
905            .find(|face| face.name.as_deref() == Some(face_name))
906            .ok_or_else(|| format!("face '{face_name}' not found"))?;
907        let surface_type = face
908            .surface
909            .analytic()
910            .map(|analytic| analytic.kind_label())
911            .unwrap_or("NURBS");
912        Ok((
913            face_area(face)?,
914            face_boundary_length(solid, face)?,
915            surface_type,
916        ))
917    })
918}
919
920/// 3D arc length (mm) of a resident solid's named edge. Errs if no edge carries
921/// `edge_name`.
922pub fn edge_length_native(handle: u32, edge_name: &str) -> Result<f64, String> {
923    with_registered_solid_str(handle, |solid| {
924        let edge = solid
925            .edges
926            .iter()
927            .find(|edge| edge.name.as_deref() == Some(edge_name))
928            .ok_or_else(|| format!("edge '{edge_name}' not found"))?;
929        edge_arc_length(edge)
930    })
931}
932
933/// The carrier dimensions of a resident solid's named face, for the Info
934/// panel: a cylinder's radius and diameter, a cone's half-angle, a sphere's
935/// radius, a torus's radii (see [`crate::surface_carrier_dimensions`]).
936/// `Ok(None)` for a plane or a free-form face; errs if no face carries
937/// `face_name`.
938pub fn face_carrier_dimensions_native(
939    handle: u32,
940    face_name: &str,
941) -> Result<Option<crate::CarrierDimensions>, String> {
942    with_registered_solid_str(handle, |solid| {
943        let face = solid
944            .shells
945            .iter()
946            .flat_map(|shell| &shell.faces)
947            .find(|face| face.name.as_deref() == Some(face_name))
948            .ok_or_else(|| format!("face '{face_name}' not found"))?;
949        Ok(crate::surface_carrier_dimensions(&face.surface))
950    })
951}
952
953/// The circle a resident solid's named edge lies on, when it does: radius,
954/// diameter and an arc's sweep (see [`crate::curve_carrier_dimensions`]).
955/// `Ok(None)` for a line or any other curve; errs if no edge carries
956/// `edge_name`.
957pub fn edge_carrier_dimensions_native(
958    handle: u32,
959    edge_name: &str,
960) -> Result<Option<crate::CarrierDimensions>, String> {
961    with_registered_solid_str(handle, |solid| {
962        let edge = solid
963            .edges
964            .iter()
965            .find(|edge| edge.name.as_deref() == Some(edge_name))
966            .ok_or_else(|| format!("edge '{edge_name}' not found"))?;
967        if edge.degenerate {
968            return Ok(None);
969        }
970        Ok(crate::curve_carrier_dimensions(&edge.curve, edge.t0, edge.t1))
971    })
972}
973
974/// Escape hatch: pull a resident solid's full topology across the boundary (for
975/// STEP export or JSON-only lanes during the migration). Prefer handle-native
976/// ops — this re-incurs the serialization cost the registry exists to avoid.
977#[wasm_bindgen]
978pub fn solid_handle_to_buffer(handle: u32) -> Result<WasmSolidBuffer, JsValue> {
979    with_registered_solid(handle, |solid| solid_buffer(solid, "{}".into()))
980}
981
982/// Native: export the CURRENT resident solids (by handle) to an ISO-10303-21
983/// STEP document. Reads each resident solid out of the thread-local registry —
984/// the SAME registry [`display_payload_handle_native`] reads — clones them into
985/// a `Vec<BrepSolid>`, and hands the batch to [`export_step`]. The engine-native
986/// app's Export→STEP lane calls this with the handles the pipeline left resident
987/// after the last history run, so the topology never crosses a boundary as JSON.
988/// `String` error (JsValue-free — it links + runs on native and wasm alike).
989pub fn export_step_handles(
990    handles: &[u32],
991    name: &str,
992    unit: &str,
993    timestamp: &str,
994) -> Result<String, String> {
995    if handles.is_empty() {
996        return Err("export_step_handles: no solids to export".into());
997    }
998    let mut solids = Vec::with_capacity(handles.len());
999    for &handle in handles {
1000        let solid: BrepSolid = with_registered_solid_str(handle, |solid| Ok(solid.clone()))?;
1001        solids.push(solid);
1002    }
1003    export_step(&solids, name, unit, timestamp)
1004}
1005
1006/// [`export_step_handles`] with each body's SCENE NAME and an optional PMI
1007/// block: the engine-native Export→STEP lane. Names key the
1008/// `MANIFOLD_SOLID_BREP`s and the PMI reference resolution.
1009pub fn export_step_named_handles(
1010    named: &[(String, u32)],
1011    name: &str,
1012    unit: &str,
1013    timestamp: &str,
1014    pmi: Option<&crate::StepPmi<'_>>,
1015    colors: Option<&crate::StepColors>,
1016) -> Result<crate::StepExportReport, String> {
1017    if named.is_empty() {
1018        return Err("export_step_named_handles: no solids to export".into());
1019    }
1020    let mut solids = Vec::with_capacity(named.len());
1021    for (solid_name, handle) in named {
1022        let solid: BrepSolid = with_registered_solid_str(*handle, |solid| Ok(solid.clone()))?;
1023        solids.push((solid_name.clone(), solid));
1024    }
1025    let borrowed: Vec<(String, &BrepSolid)> = solids
1026        .iter()
1027        .map(|(solid_name, solid)| (solid_name.clone(), solid))
1028        .collect();
1029    crate::export_step_report_named(&borrowed, name, unit, timestamp, pmi, colors)
1030}
1031
1032/// STRUCTURED export: the resident scene PLUS the document's assembly model, as
1033/// an AP242 file with real product structure — one `PRODUCT` per parts-library
1034/// entry, one `NEXT_ASSEMBLY_USAGE_OCCURRENCE` per placed instance.
1035///
1036/// `named` is the whole resident scene (component-owned bodies included — the
1037/// tree builder filters them, because their geometry is written unposed in the
1038/// part's own product instead). `components` is one entry per ACOMP instance of
1039/// the ROOT document — `(component id, parts-library entry name, live pose)` —
1040/// which only the caller knows, since a solved or gizmo-moved instance's pose
1041/// lives in the app's projection of the scene.
1042pub fn export_step_assembly_handles(
1043    document_name: &str,
1044    named: &[(String, u32)],
1045    components: &[(String, String, crate::Mat4)],
1046    unit: &str,
1047    timestamp: &str,
1048    pmi: Option<&crate::StepPmi<'_>>,
1049    colors: Option<&crate::StepColors>,
1050) -> Result<crate::StepExportReport, String> {
1051    if named.is_empty() {
1052        return Err("export_step_assembly_handles: no solids to export".into());
1053    }
1054    let mut solids = Vec::with_capacity(named.len());
1055    for (solid_name, handle) in named {
1056        let solid: BrepSolid = with_registered_solid_str(*handle, |solid| Ok(solid.clone()))?;
1057        solids.push((solid_name.clone(), solid));
1058    }
1059    let assembly = crate::assembly_export_tree(document_name, solids, components)?;
1060    crate::export_step_assembly_report(&assembly, unit, timestamp, pmi, colors)
1061}
1062
1063/// Native: export the CURRENT resident solids (by handle) to an IGES 5.3
1064/// document of trimmed NURBS surfaces. The IGES analogue of
1065/// [`export_step_handles`] — reads the resident solids out of the thread-local
1066/// registry, clones them into a `Vec<BrepSolid>`, and hands the batch to
1067/// [`export_iges`]. `String` error (JsValue-free — links on native and wasm).
1068pub fn export_iges_handles(
1069    handles: &[u32],
1070    name: &str,
1071    unit: &str,
1072    timestamp: &str,
1073) -> Result<String, String> {
1074    if handles.is_empty() {
1075        return Err("export_iges_handles: no solids to export".into());
1076    }
1077    let mut solids = Vec::with_capacity(handles.len());
1078    for &handle in handles {
1079        let solid: BrepSolid = with_registered_solid_str(handle, |solid| Ok(solid.clone()))?;
1080        solids.push(solid);
1081    }
1082    export_iges(&solids, name, unit, timestamp)
1083}