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| {
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/// Topology validation issues of a resident solid as `(severity, message)`
348/// pairs — the native sibling of the JSON validators, for in-process
349/// qualification tooling (`examples/case_replay.rs`). An empty Vec means the
350/// incidence checks passed; that is NOT a correctness proof: `validate()` tests
351/// neither connectivity nor face-vs-face self-intersection, and a validating
352/// solid can still be the wrong solid. Reads the solid in one registry borrow.
353pub fn validate_handle_native(handle: u32) -> Result<Vec<(String, String)>, String> {
354    with_registered_solid_str(handle, |solid| {
355        Ok(solid
356            .validate()
357            .into_iter()
358            .map(|issue| (issue.severity.to_string(), issue.message))
359            .collect())
360    })
361}
362
363/// The Transform feature's BBOX_CENTER pivot for a resident source solid.
364/// Share the kernel's vertex-bbox definition with viewport controls; display
365/// tessellation bounds would give a different pivot for curved solids.
366pub fn transform_pivot_native(handle: u32) -> Result<[f64; 3], String> {
367    with_registered_solid_str(handle, |solid| {
368        Ok(crate::feature_pipeline::transform_bbox_center(solid))
369    })
370}
371
372/// Total 3D arc length (mm) of every non-degenerate edge of a resident solid —
373/// the Properties panel's "total edge length" measurement. Native sibling of the
374/// mass-properties accessors; one short registry borrow, topology never crosses
375/// the boundary.
376pub fn solid_edge_length_total_native(handle: u32) -> Result<f64, String> {
377    with_registered_solid_str(handle, solid_edge_length_total)
378}
379
380/// `(area, boundary_edge_total_length, surface_type)` of a resident solid's
381/// named face (mm² / mm / classification): the face's surface area, the summed
382/// arc length of its boundary edges, and a short label for its underlying
383/// carrier surface (`"Plane"`, `"Cylinder"`, `"Cone"`, `"Sphere"`, `"Torus"`,
384/// `"Surface of revolution"`, or `"NURBS"` when the exact rational patch is not
385/// a recognized analytic carrier). Errs if no face carries `face_name`.
386pub fn face_measurements_native(
387    handle: u32,
388    face_name: &str,
389) -> Result<(f64, f64, &'static str), String> {
390    with_registered_solid_str(handle, |solid| {
391        let face = solid
392            .shells
393            .iter()
394            .flat_map(|shell| &shell.faces)
395            .find(|face| face.name.as_deref() == Some(face_name))
396            .ok_or_else(|| format!("face '{face_name}' not found"))?;
397        let surface_type = face
398            .surface
399            .analytic()
400            .map(|analytic| analytic.kind_label())
401            .unwrap_or("NURBS");
402        Ok((
403            face_area(face)?,
404            face_boundary_length(solid, face)?,
405            surface_type,
406        ))
407    })
408}
409
410/// 3D arc length (mm) of a resident solid's named edge. Errs if no edge carries
411/// `edge_name`.
412pub fn edge_length_native(handle: u32, edge_name: &str) -> Result<f64, String> {
413    with_registered_solid_str(handle, |solid| {
414        let edge = solid
415            .edges
416            .iter()
417            .find(|edge| edge.name.as_deref() == Some(edge_name))
418            .ok_or_else(|| format!("edge '{edge_name}' not found"))?;
419        edge_arc_length(edge)
420    })
421}
422
423/// Escape hatch: pull a resident solid's full topology across the boundary (for
424/// STEP export or JSON-only lanes during the migration). Prefer handle-native
425/// ops — this re-incurs the serialization cost the registry exists to avoid.
426#[wasm_bindgen]
427pub fn solid_handle_to_buffer(handle: u32) -> Result<WasmSolidBuffer, JsValue> {
428    with_registered_solid(handle, |solid| solid_buffer(solid, "{}".into()))
429}
430
431/// Native: export the CURRENT resident solids (by handle) to an ISO-10303-21
432/// STEP document. Reads each resident solid out of the thread-local registry —
433/// the SAME registry [`display_payload_handle_native`] reads — clones them into
434/// a `Vec<BrepSolid>`, and hands the batch to [`export_step`]. The engine-native
435/// app's Export→STEP lane calls this with the handles the pipeline left resident
436/// after the last history run, so the topology never crosses a boundary as JSON.
437/// `String` error (JsValue-free — it links + runs on native and wasm alike).
438pub fn export_step_handles(
439    handles: &[u32],
440    name: &str,
441    unit: &str,
442    timestamp: &str,
443) -> Result<String, String> {
444    if handles.is_empty() {
445        return Err("export_step_handles: no solids to export".into());
446    }
447    let mut solids = Vec::with_capacity(handles.len());
448    for &handle in handles {
449        let solid: BrepSolid = with_registered_solid_str(handle, |solid| Ok(solid.clone()))?;
450        solids.push(solid);
451    }
452    export_step(&solids, name, unit, timestamp)
453}
454
455/// Native: export the CURRENT resident solids (by handle) to an IGES 5.3
456/// document of trimmed NURBS surfaces. The IGES analogue of
457/// [`export_step_handles`] — reads the resident solids out of the thread-local
458/// registry, clones them into a `Vec<BrepSolid>`, and hands the batch to
459/// [`export_iges`]. `String` error (JsValue-free — links on native and wasm).
460pub fn export_iges_handles(
461    handles: &[u32],
462    name: &str,
463    unit: &str,
464    timestamp: &str,
465) -> Result<String, String> {
466    if handles.is_empty() {
467        return Err("export_iges_handles: no solids to export".into());
468    }
469    let mut solids = Vec::with_capacity(handles.len());
470    for &handle in handles {
471        let solid: BrepSolid = with_registered_solid_str(handle, |solid| Ok(solid.clone()))?;
472        solids.push(solid);
473    }
474    export_iges(&solids, name, unit, timestamp)
475}