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| {
60 let chord = display_chord_tolerance(solid, lod_factor);
61 let mesh = tessellate_brep_watertight(solid, chord)?;
62 let mut faces = Vec::new();
63 for shell in &solid.shells {
64 for face in &shell.faces {
65 faces.push((face.id, face.name.clone()));
66 }
67 }
68 let edge_names: std::collections::HashMap<u64, String> = solid
69 .edges
70 .iter()
71 .filter_map(|edge| edge.name.as_ref().map(|name| (edge.id, name.clone())))
72 .collect();
73 let edges = sample_edge_polylines(solid, chord)?
74 .into_iter()
75 .map(|(id, points)| (id, edge_names.get(&id).cloned(), points))
76 .collect();
77 let vertices = solid
78 .vertices
79 .iter()
80 .map(|vertex| (vertex.id, vertex.point))
81 .collect();
82 Ok(DisplaySolidPayload {
83 faces,
84 mesh,
85 edges,
86 vertices,
87 chord_tolerance: chord,
88 })
89 })
90}
91
92/// Native display payload for a solved sketch PROFILE — the SHEET-SOLID view of a
93/// committed sketch: a planar FACE mesh + its named boundary EDGES + corner
94/// VERTICES, so a sketch is pickable / selectable / measurable through the exact
95/// same display path as a real solid. NO solid is registered — the payload is
96/// synthesized directly from the profile, so it owns no scene handle (the display
97/// carries `source_handle = 0`).
98///
99/// The FACE triangulates each region (outer boundary minus holes) via the
100/// watertight planar path, mapped to world through the profile's frame; each
101/// triangle rides face id 0 (one logical sheet face). EDGES are each boundary
102/// curve sampled to a world polyline, named from the profile's `{sketchId}:G{gid}`
103/// edge names; VERTICES are the loop corners. An empty profile (no closed region)
104/// yields an empty payload (no face, no edges) — an open/underconstrained sketch
105/// has no sheet.
106pub fn sketch_profile_display_payload(
107 profile: &crate::feature_pipeline::SketchProfile,
108) -> DisplaySolidPayload {
109 /// Samples per boundary curve — enough to resolve arcs/circles; straight
110 /// segments oversample harmlessly.
111 const SEGMENTS: usize = 24;
112 let origin = profile.origin;
113 let x_axis = profile.x_axis;
114 let y_axis = profile.y_axis;
115 let normal = profile.z_axis;
116 let to_uv = |p: Vec3| {
117 let d = p.sub(origin);
118 [d.dot(x_axis), d.dot(y_axis)]
119 };
120
121 let mut mesh = Mesh::default();
122 let mut edges: Vec<(u64, Option<String>, Vec<Vec3>)> = Vec::new();
123 let mut vertices: Vec<(u64, Vec3)> = Vec::new();
124 let mut next_edge_id: u64 = 0;
125 let mut next_vertex_id: u64 = 0;
126 let mut sketch_id: Option<String> = None;
127
128 for region in &profile.regions {
129 let Some((outer, holes)) = region.split_first() else {
130 continue;
131 };
132 // Boundary vertices (uv, world) for triangulation: sample [t0, t1) per
133 // curve so the next curve contributes the shared corner exactly once.
134 let boundary = |lp: &crate::feature_pipeline::ProfileLoop| -> Vec<([f64; 2], Vec3)> {
135 let mut out = Vec::new();
136 for curve in &lp.curves {
137 let Ok([t0, t1]) = curve.domain() else { continue };
138 for step in 0..SEGMENTS {
139 let t = t0 + (t1 - t0) * step as f64 / SEGMENTS as f64;
140 if let Ok(p) = curve.evaluate(t) {
141 out.push((to_uv(p), p));
142 }
143 }
144 }
145 out
146 };
147 let outer_b = boundary(outer);
148 let holes_b: Vec<Vec<([f64; 2], Vec3)>> = holes.iter().map(boundary).collect();
149 for [a, b, c] in watertight_tessellation::triangulate_planar_region(&outer_b, &holes_b) {
150 let base = (mesh.positions.len() / 3) as u32;
151 for p in [a, b, c] {
152 mesh.positions.extend([p.x, p.y, p.z]);
153 mesh.normals.extend([normal.x, normal.y, normal.z]);
154 }
155 mesh.indices.extend([base, base + 1, base + 2]);
156 mesh.face_ids.push(0);
157 }
158
159 // Named boundary EDGES + corner VERTICES for every loop of this region.
160 for lp in region {
161 for (index, curve) in lp.curves.iter().enumerate() {
162 let Ok([t0, t1]) = curve.domain() else { continue };
163 let mut polyline = Vec::with_capacity(SEGMENTS + 1);
164 for step in 0..=SEGMENTS {
165 let t = t0 + (t1 - t0) * step as f64 / SEGMENTS as f64;
166 if let Ok(p) = curve.evaluate(t) {
167 polyline.push(p);
168 }
169 }
170 let name = lp.edge_names.get(index).cloned().flatten();
171 if sketch_id.is_none() {
172 // `{sketchId}:G{gid}` → the sheet's face inherits `{sketchId}`.
173 sketch_id = name
174 .as_deref()
175 .and_then(|n| n.split_once(":G").map(|(id, _)| id.to_string()));
176 }
177 if polyline.len() >= 2 {
178 edges.push((next_edge_id, name, polyline));
179 next_edge_id += 1;
180 }
181 if let Ok(p) = curve.evaluate(t0) {
182 vertices.push((next_vertex_id, p));
183 next_vertex_id += 1;
184 }
185 }
186 }
187 }
188
189 // One logical sheet face (id 0), only if the mesh has triangles.
190 let faces = if mesh.face_ids.is_empty() {
191 Vec::new()
192 } else {
193 let face_name = sketch_id.map(|id| format!("{id}:FACE"));
194 vec![(0u64, face_name)]
195 };
196 DisplaySolidPayload {
197 faces,
198 mesh,
199 edges,
200 vertices,
201 chord_tolerance: 0.0,
202 }
203}
204
205/// Native display payload for a whole committed SKETCH: its profile SHEET (when
206/// the sketch closes a region) PLUS every model SEGMENT the sheet does not
207/// already draw.
208///
209/// [`sketch_profile_display_payload`] draws a sketch through its closed profile,
210/// and that is the only display a committed sketch had. A sketch that closes
211/// NOTHING — one line, an open chain, a trajectory drawn alongside a closed loop —
212/// publishes no profile at all, so it was handed to the sheet builder as `None`
213/// and drew nothing: invisible in 3D, and unpickable there (the 2026-09-02 report,
214/// *"Sketch with single edge not visible in 3D"*). Its geometry was never missing,
215/// only unaccepted — the SKETCH feature already publishes one world curve per
216/// model segment under `{sketchId}:G{gid}`, which is exactly what `segments`
217/// carries here.
218///
219/// Each such segment draws as one NAMED edge (the same name a downstream
220/// `reference_selection` stores, so picking the drawn line resolves) with its
221/// endpoints as vertices. A segment whose name the sheet already drew is SKIPPED,
222/// so a closed sketch draws each boundary edge exactly once and a mixed sketch
223/// draws its loop and its open chain side by side. Endpoints are deduped against
224/// the points already drawn — consecutive segments of one chain share a junction.
225///
226/// `points` are the sketch's standalone MODEL points (world space): a sketch
227/// with points only — a hole-placement sketch — has no segment at all, so it
228/// drew nothing and was invisible and unpickable in 3D. Each point draws as a
229/// vertex, deduped against the endpoints the segments already drew, so a point
230/// that is also a segment corner draws once.
231///
232/// Like its sheet sibling this registers no solid: the payload is synthesized
233/// directly from the profile, the curves and the points.
234pub fn sketch_display_payload(
235 profile: Option<&crate::feature_pipeline::SketchProfile>,
236 segments: &[(String, Vec<NurbsCurve>)],
237 points: &[Vec3],
238) -> DisplaySolidPayload {
239 /// Samples per segment for a simple curve — the sheet builder's own
240 /// resolution. A curve with many knot spans (a fitted helix has ~64 per
241 /// turn) gets four samples per span instead, so a long fitted curve draws
242 /// as itself rather than as a coarse polygon; capped so a pathological
243 /// knot vector cannot flood the display.
244 const SEGMENTS: usize = 24;
245 const SAMPLES_PER_SPAN: usize = 4;
246 const MAX_SEGMENTS: usize = 4096;
247 /// Two drawn endpoints closer than this are the same corner (a chain's
248 /// junction points are the SAME solved sketch point, so they agree exactly).
249 const SAME_POINT: f64 = 1e-9;
250
251 let mut payload = match profile {
252 Some(profile) => sketch_profile_display_payload(profile),
253 None => DisplaySolidPayload {
254 faces: Vec::new(),
255 mesh: Mesh::default(),
256 edges: Vec::new(),
257 vertices: Vec::new(),
258 chord_tolerance: 0.0,
259 },
260 };
261
262 let drawn: std::collections::HashSet<String> = payload
263 .edges
264 .iter()
265 .filter_map(|(_, name, _)| name.clone())
266 .collect();
267 let mut next_edge_id = payload
268 .edges
269 .iter()
270 .map(|(id, _, _)| id + 1)
271 .max()
272 .unwrap_or(0);
273 let mut next_vertex_id = payload.vertices.iter().map(|(id, _)| id + 1).max().unwrap_or(0);
274
275 for (name, curves) in segments {
276 if drawn.contains(name) {
277 continue;
278 }
279 for curve in curves {
280 let Ok([t0, t1]) = curve.domain() else { continue };
281 let spans = curve
282 .knots
283 .windows(2)
284 .filter(|pair| pair[1] > pair[0])
285 .count();
286 let segments = (spans * SAMPLES_PER_SPAN).clamp(SEGMENTS, MAX_SEGMENTS);
287 let mut polyline = Vec::with_capacity(segments + 1);
288 for step in 0..=segments {
289 let t = t0 + (t1 - t0) * step as f64 / segments as f64;
290 if let Ok(point) = curve.evaluate(t) {
291 polyline.push(point);
292 }
293 }
294 if polyline.len() < 2 {
295 continue;
296 }
297 for end in [polyline[0], polyline[polyline.len() - 1]] {
298 if payload
299 .vertices
300 .iter()
301 .any(|(_, point)| point.sub(end).length() <= SAME_POINT)
302 {
303 continue;
304 }
305 payload.vertices.push((next_vertex_id, end));
306 next_vertex_id += 1;
307 }
308 payload.edges.push((next_edge_id, Some(name.clone()), polyline));
309 next_edge_id += 1;
310 }
311 }
312
313 // Standalone points: one vertex each, skipping any position a segment
314 // endpoint (or an earlier point) already drew.
315 for &point in points {
316 if payload
317 .vertices
318 .iter()
319 .any(|(_, drawn)| drawn.sub(point).length() <= SAME_POINT)
320 {
321 continue;
322 }
323 payload.vertices.push((next_vertex_id, point));
324 next_vertex_id += 1;
325 }
326 payload
327}
328
329/// Full mass properties of a resident solid, scaled to `density` (the native,
330/// non-wasm sibling of [`mass_properties_handle`] for the in-process renderer):
331/// volume + surface area + centroid + centroidal inertia tensor + principal
332/// axes/moments (Golovanov §8.11). The underlying geometry is unit-density; here
333/// `mass = density * volume` and every inertia quantity scales linearly with
334/// density (centroid + principal axes are density-independent). `density` is in
335/// mass units per mm³ (the kernel's length convention is millimetres); pass
336/// `1.0` for the raw geometric result (`mass == volume`). Reads the solid in one
337/// registry borrow; the topology never crosses a boundary.
338pub fn mass_properties_handle_native(
339 handle: u32,
340 density: f64,
341) -> Result<DensityMassProperties, String> {
342 with_registered_solid_str(handle, |solid| {
343 Ok(solid_mass_properties_full(solid)?.with_density(density))
344 })
345}
346
347/// Surface area and volume of a resident solid — the native sibling of
348/// [`mass_properties_handle_native`] for a caller that reads NOTHING but
349/// those two numbers (`examples/case_replay.rs` records volume and area per
350/// solid and discards the rest). The full entry runs a second station sweep
351/// over every trimmed face for the nine moment integrands, which costs as
352/// much again as the area/volume sweep: on `anotherBooleanFail`'s imported
353/// 32-face body 2.6 s against 1.3 s, and 5.3 s of that case's 12.9 s replay
354/// went to moments nobody read. The two numbers come out of the same
355/// `solid_mass_properties` call the full entry starts with, so they are
356/// bit-identical to its `volume` and `surface_area`; the full entry's
357/// refusal of a non-positive volume is kept so a caller switching over sees
358/// the same `Err` on the same solids. Reads the solid in one registry borrow.
359pub fn mass_measures_handle_native(handle: u32) -> Result<MassProperties, String> {
360 with_registered_solid_str(handle, |solid| {
361 let _caller = crate::mass_caller("abi.mass_measures");
362 let properties = solid_mass_properties(solid)?;
363 if properties.volume.abs() <= 1e-30 {
364 return Err("solid_mass_properties_full: non-positive volume".into());
365 }
366 Ok(properties)
367 })
368}
369
370/// Topology validation issues of a resident solid as `(severity, message)`
371/// pairs — the native sibling of the JSON validators, for in-process
372/// qualification tooling (`examples/case_replay.rs`). An empty Vec means the
373/// incidence checks passed; that is NOT a correctness proof: `validate()` tests
374/// neither connectivity nor face-vs-face self-intersection, and a validating
375/// solid can still be the wrong solid. Reads the solid in one registry borrow.
376pub fn validate_handle_native(handle: u32) -> Result<Vec<(String, String)>, String> {
377 with_registered_solid_str(handle, |solid| {
378 Ok(solid
379 .validate()
380 .into_iter()
381 .map(|issue| (issue.severity.to_string(), issue.message))
382 .collect())
383 })
384}
385
386/// Topology TOTALS of a resident solid as
387/// `(faces, edges, non_degenerate_edges, vertices)`, read straight off the
388/// record: every face of every shell, every edge, every edge that is not a
389/// collapsed pole boundary, every vertex.
390///
391/// The two edge counts answer different questions and both are worth
392/// recording. The RAW total is what a reader means by "15 edges" when looking
393/// at the record, and it is the right thing to diff between two runs. The
394/// NON-DEGENERATE count is the one a derivation can state from the intended
395/// construction, because it excludes the seam and pole boundaries the kernel
396/// chose as scaffolding rather than anything the shape has.
397///
398/// These are what a case gate compares. A solid can keep its volume to the
399/// last digit while gaining or losing edges — `inbox-20260909-tube-joint-edge-splits`
400/// carried "7 faces / 17 edges / 11 vertices" in prose for a month with every
401/// local assertion in its suite passing — so the totals are recorded and
402/// diffed.
403///
404/// Since 2026-09-20 they can also be STATED. The two checks answer different
405/// questions and both are kept — a diff against the baseline says "did this
406/// change", a claim says "was it ever right" — and it is the NON-degenerate
407/// count that a claim may state, for the reason above. Reads the solid in one
408/// registry borrow.
409pub fn topology_counts_native(handle: u32) -> Result<(usize, usize, usize, usize), String> {
410 with_registered_solid_str(handle, |solid| {
411 Ok((
412 solid.shells.iter().map(|shell| shell.faces.len()).sum(),
413 solid.edges.len(),
414 solid.edges.iter().filter(|edge| !edge.degenerate).count(),
415 solid.vertices.len(),
416 ))
417 })
418}
419
420/// Connectivity of a resident solid — the check `validate()` does NOT make.
421/// Per-shell edge-connected face components, edges shared across shells, and
422/// pinch vertices. Pure topology, no geometry, no tolerance; see
423/// [`crate::solid_connectivity`] for which part is a gate and which is a
424/// diagnostic. Reads the solid in one registry borrow.
425pub fn connectivity_handle_native(handle: u32) -> Result<crate::ConnectivityReport, String> {
426 with_registered_solid_str(handle, |solid| Ok(crate::solid_connectivity(solid)))
427}
428
429/// Closedness and Euler parity of a resident solid — the THIRD check
430/// `validate()` does not make on its own. Its Euler comparison reduces, on a
431/// freshly derived solid, to exactly "is the characteristic even", and it is
432/// switched off on any solid carrying a degenerate edge; this asks the question
433/// directly and on every solid. Pure counting, one registry borrow.
434pub fn euler_handle_native(handle: u32) -> Result<crate::EulerReport, String> {
435 with_registered_solid_str(handle, |solid| Ok(crate::solid_euler(solid)))
436}
437
438/// The body-kind report of a resident solid. A report beside the Euler
439/// one, not a gate: no body carries a kind yet. Pure counting, one
440/// registry borrow.
441pub fn topology_kind_handle_native(handle: u32) -> Result<crate::KindReport, String> {
442 with_registered_solid_str(handle, |solid| Ok(crate::topology_kind(solid)))
443}
444
445/// The kind a resident solid DECLARES (`BrepSolid::kind`), as distinct
446/// from the kind the report above reads from the edge census. `Solid`
447/// for every body built today.
448pub fn body_kind_handle_native(handle: u32) -> Result<crate::BodyKind, String> {
449 with_registered_solid_str(handle, |solid| Ok(solid.kind))
450}
451
452/// Face-loop self-crossing of a resident solid — the check no OTHER detector
453/// here can make. `validate()` is silent (the incidence is perfect), the
454/// face-versus-face scan has no pair to confirm (both triangles are on one
455/// face), connectivity and Euler parity count the same cells either way; the
456/// face's AREA is the only other tell. Pure parameter-space work on the trims
457/// the face already carries — no tessellation — so it is cheap enough to be a
458/// default. Reads the solid in one registry borrow.
459pub fn loop_crossings_handle_native(handle: u32) -> Result<crate::LoopCrossingReport, String> {
460 with_registered_solid_str(handle, |solid| Ok(crate::loop_self_crossings(solid)))
461}
462
463/// The vector-area closure of a resident solid: does each closed shell's
464/// TRIMS enclose the zero vector area a closed shell owes, and if not, which
465/// faces own the residual. One pass over the trims and the edge curves — no
466/// tessellation, no projection — so unlike the crossing scan it is cheap
467/// enough for a default gate. Reads the solid in one registry borrow.
468pub fn vector_area_handle_native(handle: u32) -> Result<crate::VectorAreaReport, String> {
469 with_registered_solid_str(handle, |solid| Ok(crate::shell_vector_areas(solid)))
470}
471
472/// Face-versus-face self-intersection of a resident solid — the OTHER check
473/// `validate()` does not make (its one "self-intersects" string is a uv-wire
474/// warning). Opt-in and expensive: it tessellates the solid and walks a
475/// triangle BVH, so no default gate calls it. `chord_tolerance` overrides the
476/// per-solid display density when positive. Reads the solid in one registry
477/// borrow.
478pub fn self_intersections_handle_native(
479 handle: u32,
480 chord_tolerance: f64,
481) -> Result<crate::SelfIntersectionReport, String> {
482 with_registered_solid_str(handle, |solid| {
483 let mut options = crate::SelfIntersectionOptions::for_solid(solid);
484 if chord_tolerance > 0.0 && chord_tolerance.is_finite() {
485 options.chord_tolerance = chord_tolerance;
486 }
487 crate::solid_self_intersections(solid, options)
488 })
489}
490
491/// The Transform feature's BBOX_CENTER pivot for a resident source solid.
492/// Share the kernel's vertex-bbox definition with viewport controls; display
493/// tessellation bounds would give a different pivot for curved solids.
494pub fn transform_pivot_native(handle: u32) -> Result<[f64; 3], String> {
495 with_registered_solid_str(handle, |solid| {
496 Ok(crate::feature_pipeline::transform_bbox_center(solid))
497 })
498}
499
500/// Total 3D arc length (mm) of every non-degenerate edge of a resident solid —
501/// the Properties panel's "total edge length" measurement. Native sibling of the
502/// mass-properties accessors; one short registry borrow, topology never crosses
503/// the boundary.
504pub fn solid_edge_length_total_native(handle: u32) -> Result<f64, String> {
505 with_registered_solid_str(handle, solid_edge_length_total)
506}
507
508/// `(area, boundary_edge_total_length, surface_type)` of a resident solid's
509/// named face (mm² / mm / classification): the face's surface area, the summed
510/// arc length of its boundary edges, and a short label for its underlying
511/// carrier surface (`"Plane"`, `"Cylinder"`, `"Cone"`, `"Sphere"`, `"Torus"`,
512/// `"Surface of revolution"`, or `"NURBS"` when the exact rational patch is not
513/// a recognized analytic carrier). Errs if no face carries `face_name`.
514pub fn face_measurements_native(
515 handle: u32,
516 face_name: &str,
517) -> Result<(f64, f64, &'static str), String> {
518 with_registered_solid_str(handle, |solid| {
519 let face = solid
520 .shells
521 .iter()
522 .flat_map(|shell| &shell.faces)
523 .find(|face| face.name.as_deref() == Some(face_name))
524 .ok_or_else(|| format!("face '{face_name}' not found"))?;
525 let surface_type = face
526 .surface
527 .analytic()
528 .map(|analytic| analytic.kind_label())
529 .unwrap_or("NURBS");
530 Ok((
531 face_area(face)?,
532 face_boundary_length(solid, face)?,
533 surface_type,
534 ))
535 })
536}
537
538/// 3D arc length (mm) of a resident solid's named edge. Errs if no edge carries
539/// `edge_name`.
540pub fn edge_length_native(handle: u32, edge_name: &str) -> Result<f64, String> {
541 with_registered_solid_str(handle, |solid| {
542 let edge = solid
543 .edges
544 .iter()
545 .find(|edge| edge.name.as_deref() == Some(edge_name))
546 .ok_or_else(|| format!("edge '{edge_name}' not found"))?;
547 edge_arc_length(edge)
548 })
549}
550
551/// The carrier dimensions of a resident solid's named face, for the Info
552/// panel: a cylinder's radius and diameter, a cone's half-angle, a sphere's
553/// radius, a torus's radii (see [`crate::surface_carrier_dimensions`]).
554/// `Ok(None)` for a plane or a free-form face; errs if no face carries
555/// `face_name`.
556pub fn face_carrier_dimensions_native(
557 handle: u32,
558 face_name: &str,
559) -> Result<Option<crate::CarrierDimensions>, String> {
560 with_registered_solid_str(handle, |solid| {
561 let face = solid
562 .shells
563 .iter()
564 .flat_map(|shell| &shell.faces)
565 .find(|face| face.name.as_deref() == Some(face_name))
566 .ok_or_else(|| format!("face '{face_name}' not found"))?;
567 Ok(crate::surface_carrier_dimensions(&face.surface))
568 })
569}
570
571/// The circle a resident solid's named edge lies on, when it does: radius,
572/// diameter and an arc's sweep (see [`crate::curve_carrier_dimensions`]).
573/// `Ok(None)` for a line or any other curve; errs if no edge carries
574/// `edge_name`.
575pub fn edge_carrier_dimensions_native(
576 handle: u32,
577 edge_name: &str,
578) -> Result<Option<crate::CarrierDimensions>, String> {
579 with_registered_solid_str(handle, |solid| {
580 let edge = solid
581 .edges
582 .iter()
583 .find(|edge| edge.name.as_deref() == Some(edge_name))
584 .ok_or_else(|| format!("edge '{edge_name}' not found"))?;
585 if edge.degenerate {
586 return Ok(None);
587 }
588 Ok(crate::curve_carrier_dimensions(&edge.curve, edge.t0, edge.t1))
589 })
590}
591
592/// Escape hatch: pull a resident solid's full topology across the boundary (for
593/// STEP export or JSON-only lanes during the migration). Prefer handle-native
594/// ops — this re-incurs the serialization cost the registry exists to avoid.
595#[wasm_bindgen]
596pub fn solid_handle_to_buffer(handle: u32) -> Result<WasmSolidBuffer, JsValue> {
597 with_registered_solid(handle, |solid| solid_buffer(solid, "{}".into()))
598}
599
600/// Native: export the CURRENT resident solids (by handle) to an ISO-10303-21
601/// STEP document. Reads each resident solid out of the thread-local registry —
602/// the SAME registry [`display_payload_handle_native`] reads — clones them into
603/// a `Vec<BrepSolid>`, and hands the batch to [`export_step`]. The engine-native
604/// app's Export→STEP lane calls this with the handles the pipeline left resident
605/// after the last history run, so the topology never crosses a boundary as JSON.
606/// `String` error (JsValue-free — it links + runs on native and wasm alike).
607pub fn export_step_handles(
608 handles: &[u32],
609 name: &str,
610 unit: &str,
611 timestamp: &str,
612) -> Result<String, String> {
613 if handles.is_empty() {
614 return Err("export_step_handles: no solids to export".into());
615 }
616 let mut solids = Vec::with_capacity(handles.len());
617 for &handle in handles {
618 let solid: BrepSolid = with_registered_solid_str(handle, |solid| Ok(solid.clone()))?;
619 solids.push(solid);
620 }
621 export_step(&solids, name, unit, timestamp)
622}
623
624/// [`export_step_handles`] with each body's SCENE NAME and an optional PMI
625/// block: the engine-native Export→STEP lane. Names key the
626/// `MANIFOLD_SOLID_BREP`s and the PMI reference resolution.
627pub fn export_step_named_handles(
628 named: &[(String, u32)],
629 name: &str,
630 unit: &str,
631 timestamp: &str,
632 pmi: Option<&crate::StepPmi<'_>>,
633 colors: Option<&crate::StepColors>,
634) -> Result<crate::StepExportReport, String> {
635 if named.is_empty() {
636 return Err("export_step_named_handles: no solids to export".into());
637 }
638 let mut solids = Vec::with_capacity(named.len());
639 for (solid_name, handle) in named {
640 let solid: BrepSolid = with_registered_solid_str(*handle, |solid| Ok(solid.clone()))?;
641 solids.push((solid_name.clone(), solid));
642 }
643 let borrowed: Vec<(String, &BrepSolid)> = solids
644 .iter()
645 .map(|(solid_name, solid)| (solid_name.clone(), solid))
646 .collect();
647 crate::export_step_report_named(&borrowed, name, unit, timestamp, pmi, colors)
648}
649
650/// STRUCTURED export: the resident scene PLUS the document's assembly model, as
651/// an AP242 file with real product structure — one `PRODUCT` per parts-library
652/// entry, one `NEXT_ASSEMBLY_USAGE_OCCURRENCE` per placed instance.
653///
654/// `named` is the whole resident scene (component-owned bodies included — the
655/// tree builder filters them, because their geometry is written unposed in the
656/// part's own product instead). `components` is one entry per ACOMP instance of
657/// the ROOT document — `(component id, parts-library entry name, live pose)` —
658/// which only the caller knows, since a solved or gizmo-moved instance's pose
659/// lives in the app's projection of the scene.
660pub fn export_step_assembly_handles(
661 document_name: &str,
662 named: &[(String, u32)],
663 components: &[(String, String, crate::Mat4)],
664 unit: &str,
665 timestamp: &str,
666 pmi: Option<&crate::StepPmi<'_>>,
667 colors: Option<&crate::StepColors>,
668) -> Result<crate::StepExportReport, String> {
669 if named.is_empty() {
670 return Err("export_step_assembly_handles: no solids to export".into());
671 }
672 let mut solids = Vec::with_capacity(named.len());
673 for (solid_name, handle) in named {
674 let solid: BrepSolid = with_registered_solid_str(*handle, |solid| Ok(solid.clone()))?;
675 solids.push((solid_name.clone(), solid));
676 }
677 let assembly = crate::assembly_export_tree(document_name, solids, components)?;
678 crate::export_step_assembly_report(&assembly, unit, timestamp, pmi, colors)
679}
680
681/// Native: export the CURRENT resident solids (by handle) to an IGES 5.3
682/// document of trimmed NURBS surfaces. The IGES analogue of
683/// [`export_step_handles`] — reads the resident solids out of the thread-local
684/// registry, clones them into a `Vec<BrepSolid>`, and hands the batch to
685/// [`export_iges`]. `String` error (JsValue-free — links on native and wasm).
686pub fn export_iges_handles(
687 handles: &[u32],
688 name: &str,
689 unit: &str,
690 timestamp: &str,
691) -> Result<String, String> {
692 if handles.is_empty() {
693 return Err("export_iges_handles: no solids to export".into());
694 }
695 let mut solids = Vec::with_capacity(handles.len());
696 for &handle in handles {
697 let solid: BrepSolid = with_registered_solid_str(handle, |solid| Ok(solid.clone()))?;
698 solids.push(solid);
699 }
700 export_iges(&solids, name, unit, timestamp)
701}