Skip to main content

brep_render/
scene.rs

1//! Renderer-independent display objects keyed by kernel feature names.
2//! Rendering, picking, and feature-reference display share this scene map.
3
4use crate::camera::Aabb;
5use std::collections::HashMap;
6
7/// The kind of a display face (surface classification rides along when known —
8/// typed replacement for the previous app's untyped `faceKind` tag).
9#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
10pub enum FaceKind {
11    Unknown,
12}
13
14/// One face of a solid: a contiguous triangle range of the solid mesh plus the
15/// kernel face identity.
16#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
17pub struct FaceDisplay {
18    /// Kernel face name (byte-exact pipeline name); empty when unnamed.
19    pub name: String,
20    /// Kernel topology face id.
21    pub topo_id: u64,
22    /// First triangle (not index) of this face in the mesh.
23    pub tri_start: u32,
24    /// Triangle count.
25    pub tri_count: u32,
26    pub kind: FaceKind,
27    /// Per-FACE base colour, resolved from this face's `color` metadata
28    /// attribute by [`RenderScene::apply_metadata_colors`]. Takes precedence
29    /// over the owning solid's colour (and over `faceColorMode`), but selection
30    /// / hover emphasis still wins over it. `None` = inherit the solid.
31    #[serde(default)]
32    pub color_override: Option<[f32; 3]>,
33}
34
35/// One display edge: a world-space polyline plus the kernel edge identity and
36/// the typed flags the previous display layer kept per object.
37#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
38pub struct EdgeDisplay {
39    /// Kernel edge name (`faceA|faceB[n]` convention); empty when unnamed.
40    pub name: String,
41    /// Kernel topology edge id.
42    pub topo_id: u64,
43    /// World-space polyline (chord-tolerance sampled, ≥ 2 points).
44    pub polyline: Vec<[f32; 3]>,
45    /// Auxiliary display edge (not a real BREP boundary): drawn, but never
46    /// picked, listed or named — a board's copper outline is one.
47    pub aux: bool,
48    /// Centerline flag (hole/revolve axis display).
49    pub centerline: bool,
50}
51
52/// One display vertex (kernel topology vertex).
53#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
54pub struct VertexDisplay {
55    pub topo_id: u64,
56    pub position: [f64; 3],
57}
58
59/// The triangle mesh of one solid, ready for GPU upload (f32; the kernel's f64
60/// buffers are narrowed exactly once, here).
61#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
62pub struct DisplayMesh {
63    /// Interleaved-ready parallel arrays: xyz per vertex.
64    pub positions: Vec<[f32; 3]>,
65    pub normals: Vec<[f32; 3]>,
66    /// Triangle indices (3 per triangle).
67    pub indices: Vec<u32>,
68    /// Per-TRIANGLE face index into `SolidDisplay::faces`.
69    pub face_ids: Vec<u32>,
70}
71
72/// A displayed solid: mesh + named faces/edges/vertices + visibility.
73#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
74pub struct SolidDisplay {
75    /// Kernel solid name — the scene key.
76    pub name: String,
77    /// The resident kernel handle this display was tessellated from (0 = none,
78    /// e.g. a synthesized sheet with no kernel solid). Handles are monotonic and
79    /// never recycled, so this is a stable identity for the geometry: the R10
80    /// display-reuse fast path keeps an existing display across a history rerun
81    /// ONLY when the name's resident handle still equals this — a name re-bound to
82    /// a DIFFERENT handle (a SUBTRACT result inherits its target's name; a
83    /// roll-back replays that name's ORIGINAL producer) must re-tessellate.
84    pub source_handle: u32,
85    pub visible: bool,
86    /// Optional per-solid base color override (R14 — user-set solid color),
87    /// else the name-hashed stable color is used.
88    pub color_override: Option<[f32; 3]>,
89    /// Monotonic content revision (R10). Every freshly built display gets a
90    /// unique value, so the renderer's GPU-buffer cache re-uploads on any
91    /// rebuild — correct-by-default (equivalent to teardown-rebuild). The
92    /// reused-buffer fast path (keep a revision stable across a `reused`
93    /// pipeline result) is a follow-up optimization.
94    pub revision: u64,
95    pub mesh: DisplayMesh,
96    pub faces: Vec<FaceDisplay>,
97    pub edges: Vec<EdgeDisplay>,
98    pub vertices: Vec<VertexDisplay>,
99    /// Per-entity + group hide state (individual faces/edges/vertices, or a
100    /// whole group). Default = everything visible; see [`crate::visibility`].
101    /// Reused solids keep it across history reruns (this whole struct is cloned
102    /// forward); a re-tessellated solid resets to all-visible.
103    pub visibility: crate::visibility::EntityVisibility,
104    /// World bbox over mesh positions (edges lie on the mesh by construction).
105    pub bbox: Aabb,
106    /// This display is a SYNTHESIZED committed-sketch SHEET (planar face + named
107    /// boundary edges + corner vertices), not a kernel solid — it carries no
108    /// resident handle (`source_handle == 0`). The marker lets the UI treat a
109    /// sketch as a sketch: it is listed under "Sketches" (not among solids) yet is
110    /// pickable / selectable / measurable like any scene solid.
111    pub is_sketch: bool,
112    /// This body is SHEET METAL — its resident handle carried a `SheetTree` when
113    /// the display was built. Stamped by the pipeline from
114    /// [`brep_kernel::is_sheet_metal_handle`] on the RUNNER thread (where the
115    /// tree's thread-local is warm), so the UI thread can answer "is this a
116    /// sheet-metal body?" straight off the scene — the gate for the sheet-metal
117    /// edit features (SM Flange / Fillet / Chamfer). Immutable-correct: a tree is
118    /// attached at solid creation and dropped exactly when the handle is freed,
119    /// handles never recycle, and the display-reuse fast path clones this struct
120    /// forward only while the handle is unchanged.
121    pub is_sheet_metal: bool,
122}
123
124/// Source of monotonic [`SolidDisplay::revision`] values.
125fn next_revision() -> u64 {
126    use std::sync::atomic::{AtomicU64, Ordering};
127    static COUNTER: AtomicU64 = AtomicU64::new(1);
128    COUNTER.fetch_add(1, Ordering::Relaxed)
129}
130
131/// The scene: insertion-ordered solids + an exact name index (R8 — the
132/// `getObjectByName` heuristic-scoring lookup is replaced by this map).
133#[derive(Debug, Default)]
134pub struct RenderScene {
135    solids: Vec<SolidDisplay>,
136    index: HashMap<String, usize>,
137    /// The B-rep of the kernel displays a drawing sheet has ASKED for, keyed by
138    /// solid name with the resident handle it came from: `name -> (handle,
139    /// fingerprint, solid)`. Only a sheet reads topology, and the runner sends
140    /// it only when asked (`EngineState::request_exact_solids`), so a document
141    /// with no sheet holds none. Beside the displays rather than inside them,
142    /// because a display is cloned (the PMI explode keeps originals) and
143    /// rebuilt on the UI thread (committed sketches), and neither wants a copy
144    /// of the topology. Read through [`Self::exact_solid`], which answers only
145    /// while the handle still matches the display's. The `u64` is the solid's
146    /// [`brep_fingerprint`], taken once here rather than per projection.
147    exact: HashMap<String, (u32, u64, brep_kernel::BrepSolid)>,
148    /// Bumped whenever [`Self::exact`] gains or loses a solid, so a projection
149    /// cached without a topology is redrawn when it arrives.
150    exact_revision: u64,
151    /// Bumped whenever [`Self::set_visible`] actually CHANGES a solid's
152    /// visibility — see [`Self::visibility_revision`].
153    visibility_revision: u64,
154}
155
156impl RenderScene {
157    pub fn new() -> Self {
158        Self::default()
159    }
160
161    /// Insert or replace a solid by name (replacement keeps insertion order —
162    /// a boolean result reusing its target's name stays in place).
163    pub fn insert_solid(&mut self, solid: SolidDisplay) {
164        match self.index.get(&solid.name) {
165            Some(&slot) => self.solids[slot] = solid,
166            None => {
167                self.index.insert(solid.name.clone(), self.solids.len());
168                self.solids.push(solid);
169            }
170        }
171    }
172
173    /// Drop every solid (the scene rebuild path clears then repopulates).
174    pub fn clear(&mut self) {
175        self.solids.clear();
176        self.index.clear();
177    }
178
179    /// Empty the scene, RETURNING every solid by value (the name index is cleared
180    /// and the vec is `mem::take`-n out). The history-apply seam MOVES existing
181    /// displays out this way to reinsert the reused ones without cloning their
182    /// meshes — a scene-free [`crate::pipeline::SceneRunner`] delta is applied by
183    /// draining then reinserting in snapshot order.
184    pub fn drain(&mut self) -> Vec<SolidDisplay> {
185        self.index.clear();
186        std::mem::take(&mut self.solids)
187    }
188
189    /// Set (or clear) a solid's metadata color override, bumping its revision
190    /// so the renderer re-derives the base style. Returns false if unknown.
191    pub fn set_color_override(&mut self, name: &str, color: Option<[f32; 3]>) -> bool {
192        let Some(slot) = self.index.get(name).copied() else {
193            return false;
194        };
195        let solid = &mut self.solids[slot];
196        if solid.color_override != color {
197            solid.color_override = color;
198            solid.revision = next_revision();
199        }
200        true
201    }
202
203    /// Re-derive every solid's and face's base colour from the name-keyed
204    /// metadata store — the ONE seam through which the durable `color`
205    /// attribute reaches the display.
206    ///
207    /// `lookup` maps an object NAME (a solid's or a face's) to its resolved
208    /// colour. It returns `None` both for "no colour recorded" and for "the
209    /// display setting is overriding model colours", so this method needs to
210    /// know about neither.
211    ///
212    /// SKETCH sheets are skipped: their `color_override` is the synthesized
213    /// [`crate::engine_state::SKETCH_SHEET_COLOR`], not a metadata colour, and
214    /// re-deriving it from a store that has no record for the sheet would blank
215    /// it back to the global face colour.
216    ///
217    /// A changed solid's `revision` is bumped so the renderer re-uploads it —
218    /// and ONLY when something actually changed. That no-op guarantee is
219    /// load-bearing, not a nicety: this runs after EVERY history apply, and the
220    /// R10 GPU-buffer reuse fast path keys off a stable revision.
221    pub fn apply_metadata_colors(&mut self, lookup: impl Fn(&str) -> Option<[f32; 3]>) -> bool {
222        let mut any = false;
223        for solid in &mut self.solids {
224            if solid.is_sketch {
225                continue;
226            }
227            let mut changed = false;
228            let want = lookup(&solid.name);
229            if solid.color_override != want {
230                solid.color_override = want;
231                changed = true;
232            }
233            for face in &mut solid.faces {
234                // An unnamed face can carry no metadata record, so it always
235                // inherits the solid rather than costing a store lookup.
236                let want = if face.name.is_empty() {
237                    None
238                } else {
239                    lookup(&face.name)
240                };
241                if face.color_override != want {
242                    face.color_override = want;
243                    changed = true;
244                }
245            }
246            if changed {
247                solid.revision = next_revision();
248                any = true;
249            }
250        }
251        any
252    }
253
254    /// Set a solid's visibility (R11). Returns false if unknown.
255    pub fn set_visible(&mut self, name: &str, visible: bool) -> bool {
256        let Some(solid) = self.solid_mut(name) else {
257            return false;
258        };
259        let changed = solid.visible != visible;
260        solid.visible = visible;
261        if changed {
262            self.visibility_revision = self.visibility_revision.wrapping_add(1);
263        }
264        true
265    }
266
267    /// Monotonic counter of VISIBILITY changes — the cache key for anything
268    /// derived from which bodies are shown.
269    ///
270    /// Hiding a body is SESSION state: it writes no history, runs nothing, and
271    /// lands no exact solid, so it moves none of the counters a derived
272    /// drawing would otherwise key on. A drawing sheet draws "the solids
273    /// visible in the scene" (`sheets::project::sheet_solid_names`), so its
274    /// cached projection depends on this and nothing else could tell it.
275    ///
276    /// Only a REAL change counts. `EngineState::apply_view_display` sets every
277    /// solid's visibility from the active PMI view's hidden set on every
278    /// activation, and almost all of those calls are no-ops; counting them
279    /// would re-project the open sheet for nothing.
280    pub fn visibility_revision(&self) -> u64 {
281        self.visibility_revision
282    }
283
284    /// Scene enumeration for the host scene-tree panel (R11): names, kind,
285    /// visibility, child face/edge/vertex counts — as JSON.
286    pub fn listing_json(&self) -> String {
287        let solids: Vec<serde_json::Value> = self
288            .solids
289            .iter()
290            .map(|solid| {
291                serde_json::json!({
292                    "name": solid.name,
293                    "kind": "SOLID",
294                    "visible": solid.visible,
295                    "faces": solid.faces.len(),
296                    "edges": solid.edges.iter().filter(|e| !e.aux).count(),
297                    "vertices": solid.vertices.len(),
298                })
299            })
300            .collect();
301        serde_json::Value::Array(solids).to_string()
302    }
303
304    /// Remove a solid by exact name.
305    pub fn remove_solid(&mut self, name: &str) -> bool {
306        let Some(slot) = self.index.remove(name) else {
307            return false;
308        };
309        self.solids.remove(slot);
310        for value in self.index.values_mut() {
311            if *value > slot {
312                *value -= 1;
313            }
314        }
315        true
316    }
317
318    pub fn solid(&self, name: &str) -> Option<&SolidDisplay> {
319        self.index.get(name).map(|&slot| &self.solids[slot])
320    }
321
322    /// Drop every kept B-rep whose display is gone or now comes from a
323    /// different handle. Called after a run's displays are reconciled, so "the
324    /// display's handle" is that run's.
325    pub fn prune_exact_solids(&mut self) {
326        let (solids, index) = (&self.solids, &self.index);
327        let before = self.exact.len();
328        self.exact.retain(|name, (handle, _, _)| {
329            index.get(name).is_some_and(|&slot| solids[slot].source_handle == *handle)
330        });
331        if self.exact.len() != before {
332            self.exact_revision += 1;
333        }
334    }
335
336    /// Record the B-rep the runner sent for `name`, when `handle` is still the
337    /// one its display was tessellated from; `false` (and nothing kept) when
338    /// the display has moved on since the request went out.
339    pub fn set_exact_solid(&mut self, name: &str, handle: u32, solid: brep_kernel::BrepSolid) -> bool {
340        if handle == 0 || self.solid(name).map(|display| display.source_handle) != Some(handle) {
341            return false;
342        }
343        self.exact.insert(name.to_string(), (handle, brep_fingerprint(&solid), solid));
344        self.exact_revision += 1;
345        true
346    }
347
348    /// Forget the B-rep of `name`: its geometry changed under the SAME handle
349    /// (an assembly solve re-poses a resident solid in place), so the next
350    /// projection must ask again.
351    pub fn drop_exact_solid(&mut self, name: &str) {
352        if self.exact.remove(name).is_some() {
353            self.exact_revision += 1;
354        }
355    }
356
357    /// Forget every B-rep: the handles they were keyed by belong to a runner
358    /// that is gone (a cancel restarts its registry, which counts from one
359    /// again) or to a document that was replaced.
360    pub fn clear_exact_solids(&mut self) {
361        if !self.exact.is_empty() {
362            self.exact.clear();
363            self.exact_revision += 1;
364        }
365    }
366
367    /// See [`Self::exact`]'s revision counter.
368    pub fn exact_revision(&self) -> u64 {
369        self.exact_revision
370    }
371
372    /// The B-rep the displayed solid `name` was tessellated from, or `None`
373    /// when the scene holds none for its CURRENT handle — a synthesized sketch
374    /// sheet, or a solid whose topology did not reach this thread.
375    pub fn exact_solid(&self, name: &str) -> Option<&brep_kernel::BrepSolid> {
376        self.exact_solid_fingerprinted(name).map(|(solid, _)| solid)
377    }
378
379    /// [`Self::exact_solid`] with its content fingerprint.
380    pub fn exact_solid_fingerprinted(&self, name: &str) -> Option<(&brep_kernel::BrepSolid, u64)> {
381        let display = self.solid(name)?;
382        self.exact
383            .get(name)
384            .filter(|(handle, _, _)| *handle == display.source_handle && *handle != 0)
385            .map(|(_, fingerprint, solid)| (solid, *fingerprint))
386    }
387
388    pub fn solid_mut(&mut self, name: &str) -> Option<&mut SolidDisplay> {
389        let slot = *self.index.get(name)?;
390        Some(&mut self.solids[slot])
391    }
392
393    /// Insertion-ordered iteration (deterministic — drives draw order).
394    pub fn solids(&self) -> &[SolidDisplay] {
395        &self.solids
396    }
397
398    /// The world-space polyline of the first display edge named `name` across all
399    /// solids (widened to `f64`), or `None` when no edge carries that exact name.
400    /// The engine-native sketch pickEdges tool (S6b-2) uses this to fetch a picked
401    /// scene edge's geometry for projection into the sketch plane. There is no name
402    /// index for edges (only solids), so this is a linear scan — fine for the
403    /// interactive per-click use.
404    pub fn edge_polyline_world(&self, name: &str) -> Option<Vec<[f64; 3]>> {
405        for solid in &self.solids {
406            for edge in &solid.edges {
407                if edge.name == name {
408                    return Some(
409                        edge.polyline
410                            .iter()
411                            .map(|p| [p[0] as f64, p[1] as f64, p[2] as f64])
412                            .collect(),
413                    );
414                }
415            }
416        }
417        None
418    }
419
420    /// The name of the solid owning the first display edge named `name`, or `None`
421    /// (the companion of [`edge_polyline_world`](Self::edge_polyline_world) — the
422    /// pickEdges tool stores it as external-ref metadata).
423    pub fn edge_solid_name(&self, name: &str) -> Option<&str> {
424        for solid in &self.solids {
425            if solid.edges.iter().any(|edge| edge.name == name) {
426                return Some(&solid.name);
427            }
428        }
429        None
430    }
431
432    /// The world plane of the first display face named `name` across all solids,
433    /// as `(centroid, unit outward normal)`: the area-weighted centroid of the
434    /// face's mesh triangles and the normalized sum of their cross products. The
435    /// watertight tessellation winds every face's triangles by `same_sense`, so
436    /// the cross-product sum IS the outward normal (the stored per-vertex normals
437    /// are shading normals and can be blended at shared boundary vertices). This
438    /// is what lets an extrude/revolve whose `profile` is a resident solid FACE
439    /// (not a sketch) anchor its dimension gizmo — the engine's dimension refs
440    /// fall back to it (`EngineState::lookup_profile_plane`). Hidden solids count
441    /// too: a hidden source solid still anchors the gizmo. `None` when no face
442    /// carries that exact name, it has no triangles, or the triangles are
443    /// degenerate (zero area). Linear scan like [`edge_polyline_world`](Self::edge_polyline_world).
444    pub fn face_plane_world(&self, name: &str) -> Option<([f64; 3], [f64; 3])> {
445        for solid in &self.solids {
446            let Some(face) = solid.faces.iter().find(|face| face.name == name) else {
447                continue;
448            };
449            let positions = &solid.mesh.positions;
450            let indices = &solid.mesh.indices;
451            let start = face.tri_start as usize;
452            let end = (start + face.tri_count as usize).min(indices.len() / 3);
453            let mut weighted = [0.0f64; 3];
454            let mut normal = [0.0f64; 3];
455            let mut total_area = 0.0f64;
456            for tri in start..end {
457                let a = f64_point(positions[indices[tri * 3] as usize]);
458                let b = f64_point(positions[indices[tri * 3 + 1] as usize]);
459                let c = f64_point(positions[indices[tri * 3 + 2] as usize]);
460                let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
461                let ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
462                // Twice the signed-area vector; its length is 2·area.
463                let cross = [
464                    ab[1] * ac[2] - ab[2] * ac[1],
465                    ab[2] * ac[0] - ab[0] * ac[2],
466                    ab[0] * ac[1] - ab[1] * ac[0],
467                ];
468                let area =
469                    0.5 * (cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2]).sqrt();
470                for k in 0..3 {
471                    weighted[k] += area * (a[k] + b[k] + c[k]) / 3.0;
472                    normal[k] += cross[k];
473                }
474                total_area += area;
475            }
476            let normal_len =
477                (normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2]).sqrt();
478            if total_area <= 1e-18 || normal_len <= 1e-18 {
479                return None;
480            }
481            return Some((
482                [
483                    weighted[0] / total_area,
484                    weighted[1] / total_area,
485                    weighted[2] / total_area,
486                ],
487                [normal[0] / normal_len, normal[1] / normal_len, normal[2] / normal_len],
488            ));
489        }
490        None
491    }
492
493    pub fn is_empty(&self) -> bool {
494        self.solids.is_empty()
495    }
496
497    /// World bbox over every VISIBLE solid.
498    pub fn bbox(&self) -> Aabb {
499        let mut bbox = Aabb::empty();
500        for solid in &self.solids {
501            if solid.visible {
502                bbox.union(&solid.bbox);
503            }
504        }
505        bbox
506    }
507}
508
509/// A hash of everything in a B-rep the drawing sheet's hidden-line pass reads: every vertex,
510/// curve, carrier and pcurve number to the bit, and the topology that ties
511/// them together.
512pub fn brep_fingerprint(solid: &brep_kernel::BrepSolid) -> u64 {
513    use std::hash::{Hash, Hasher};
514    let mut hasher = std::collections::hash_map::DefaultHasher::new();
515    let mut numbers = |values: &mut dyn Iterator<Item = f64>| {
516        for value in values {
517            value.to_bits().hash(&mut hasher);
518        }
519    };
520    let curve = |curve: &brep_kernel::NurbsCurve| -> Vec<f64> {
521        let mut out = vec![curve.degree as f64];
522        out.extend(curve.knots.iter().copied());
523        out.extend(curve.control_points.iter().flat_map(|cp| [cp.x, cp.y, cp.z, cp.w]));
524        out
525    };
526    for vertex in &solid.vertices {
527        numbers(&mut [vertex.id as f64, vertex.point.x, vertex.point.y, vertex.point.z].into_iter());
528    }
529    for edge in &solid.edges {
530        numbers(&mut [edge.id as f64, edge.t0, edge.t1, edge.start_vertex_id as f64, edge.end_vertex_id as f64].into_iter());
531        numbers(&mut [edge.degenerate as u8 as f64].into_iter());
532        numbers(&mut curve(&edge.curve).into_iter());
533    }
534    for shell in &solid.shells {
535        for face in &shell.faces {
536            let surface = &face.surface;
537            numbers(&mut [face.same_sense as u8 as f64, surface.degree_u as f64, surface.degree_v as f64].into_iter());
538            numbers(&mut surface.knots_u.iter().chain(&surface.knots_v).copied());
539            numbers(&mut surface.control_points.iter().flatten().flat_map(|cp| [cp.x, cp.y, cp.z, cp.w]));
540            for lp in &face.loops {
541                numbers(&mut [f64::NAN].into_iter());
542                for coedge in &lp.coedges {
543                    numbers(&mut [coedge.edge_id as f64, coedge.forward as u8 as f64].into_iter());
544                    numbers(&mut curve(&coedge.pcurve).into_iter());
545                }
546            }
547        }
548    }
549    hasher.finish()
550}
551
552/// Widen a display-mesh vertex to `f64` (the mesh stores `f32`).
553fn f64_point(p: [f32; 3]) -> [f64; 3] {
554    [p[0] as f64, p[1] as f64, p[2] as f64]
555}
556
557/// Build a [`SolidDisplay`] from the kernel's native display payload.
558pub fn solid_display_from_payload(
559    name: &str,
560    payload: brep_kernel::DisplaySolidPayload,
561) -> SolidDisplay {
562    let mesh_in = payload.mesh;
563    let vertex_count = mesh_in.positions.len() / 3;
564    let mut positions = Vec::with_capacity(vertex_count);
565    let mut normals = Vec::with_capacity(vertex_count);
566    let mut bbox = Aabb::empty();
567    for i in 0..vertex_count {
568        let p = [
569            mesh_in.positions[i * 3],
570            mesh_in.positions[i * 3 + 1],
571            mesh_in.positions[i * 3 + 2],
572        ];
573        bbox.expand(p);
574        positions.push([p[0] as f32, p[1] as f32, p[2] as f32]);
575        normals.push([
576            mesh_in.normals[i * 3] as f32,
577            mesh_in.normals[i * 3 + 1] as f32,
578            mesh_in.normals[i * 3 + 2] as f32,
579        ]);
580    }
581
582    // Face ranges: the watertight mesh emits each face's triangles as one
583    // contiguous run of `face_ids`. Group runs; a face with no triangles gets
584    // an empty range.
585    let mut faces: Vec<FaceDisplay> = payload
586        .faces
587        .iter()
588        .map(|(topo_id, name)| FaceDisplay {
589            name: name.clone().unwrap_or_default(),
590            topo_id: *topo_id,
591            tri_start: 0,
592            tri_count: 0,
593            kind: FaceKind::Unknown,
594            color_override: None,
595        })
596        .collect();
597    let mut run_start = 0u32;
598    let mut run_face: Option<u32> = None;
599    for (tri, &face_id) in mesh_in.face_ids.iter().enumerate() {
600        if run_face != Some(face_id) {
601            run_face = Some(face_id);
602            run_start = tri as u32;
603        }
604        if let Some(face) = faces.get_mut(face_id as usize) {
605            if face.tri_count == 0 {
606                face.tri_start = run_start;
607            }
608            face.tri_count += 1;
609        }
610    }
611
612    // Edges and vertices expand the bbox too. For a real solid they lie ON the
613    // meshed boundary, so this changes nothing; for a synthesized OPEN-sketch
614    // display (edges + endpoints, no face) they are the ONLY extent there is, and
615    // an empty bbox would leave the sketch out of zoom-to-fit and out of every
616    // bbox-gated traversal.
617    for (_, _, points) in &payload.edges {
618        for point in points {
619            bbox.expand([point.x, point.y, point.z]);
620        }
621    }
622    for (_, point) in &payload.vertices {
623        bbox.expand([point.x, point.y, point.z]);
624    }
625
626    let edges = payload
627        .edges
628        .into_iter()
629        .map(|(topo_id, name, points)| EdgeDisplay {
630            name: name.unwrap_or_default(),
631            topo_id,
632            polyline: points
633                .iter()
634                .map(|p| [p.x as f32, p.y as f32, p.z as f32])
635                .collect(),
636            aux: false,
637            centerline: false,
638        })
639        .collect();
640
641    let vertices = payload
642        .vertices
643        .into_iter()
644        .map(|(topo_id, p)| VertexDisplay {
645            topo_id,
646            position: [p.x, p.y, p.z],
647        })
648        .collect();
649
650    SolidDisplay {
651        name: name.to_string(),
652        source_handle: 0, // set by the caller that knows the resident handle
653        visible: true,
654        color_override: None,
655        revision: next_revision(),
656        mesh: DisplayMesh {
657            positions,
658            normals,
659            indices: mesh_in.indices,
660            face_ids: mesh_in.face_ids,
661        },
662        faces,
663        edges,
664        vertices,
665        visibility: crate::visibility::EntityVisibility::default(),
666        bbox,
667        is_sketch: false, // set by the sketch-sheet synthesizer for a committed sketch
668        is_sheet_metal: false, // stamped by the pipeline from the resident handle's SheetTree
669    }
670}