Skip to main content

brep_render/
pipeline.rs

1//! History → scene: run a whole feature history through the kernel's native
2//! `execute_history` (same process, same thread — the solid registry is
3//! thread-local) and populate a [`RenderScene`] from the resident handles via
4//! the kernel's native display payload accessor. No JSON, no typed-array
5//! boundary — the R1 promise.
6
7use crate::scene::{solid_display_from_payload, RenderScene, SolidDisplay};
8use brep_kernel::{display_payload_handle_native, execute_history, HistoryRequest};
9use std::collections::HashMap;
10
11/// Non-fatal diagnostics from a scene build (mirrors the previous app's run-history
12/// reporting: a failed feature halts the remaining features but the solids
13/// built so far still display — seeing what a failing history DID build is the
14/// point of the artifact).
15#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
16pub struct SceneBuildReport {
17    /// Per-feature hard errors, as `"<feature id>: <message>"`.
18    pub feature_errors: Vec<String>,
19    /// Unresolved reference-selection names, as `"<feature id>: <name>"`.
20    pub unresolved: Vec<String>,
21    /// Solids whose display payload (tessellation) failed, as
22    /// `"<solid name>: <message>"` — the solid is skipped, not fatal.
23    pub display_errors: Vec<String>,
24    /// Per-feature wall-clock execution time `(feature id, milliseconds)` in run
25    /// order, carried through from the kernel's [`brep_kernel::HistoryResult`]
26    /// timings — the history tree's "N ms" readout.
27    pub feature_timings: Vec<(String, f64)>,
28    /// Per-feature output solid names `(feature id, [solid names])` in run order —
29    /// the history tree's read-only "Outputs" node.
30    pub feature_outputs: Vec<(String, Vec<String>)>,
31    /// Named plane FRAMES this run resolved `(frame name, frame)`, in run order —
32    /// every DATUM registers three (`{id}:XY|XZ|YZ`), every PLANE one (`{id}`), and
33    /// a SKETCH its own plane (`{id}`). Surfaced here so the engine can DISPLAY the
34    /// construction datum/plane frames (filtered to the D/P producing features) as
35    /// first-class scene citizens — the resolved frames ride
36    /// [`brep_kernel::FeatureResult::frames`] straight through, no kernel change.
37    pub frames: Vec<(String, brep_kernel::Frame)>,
38    /// Solved sketch PROFILES this run produced `(sketch id, profile)`, in run
39    /// order — every SKETCH feature publishes one under its own id. Surfaced here
40    /// (exactly like [`Self::frames`]) so the engine can display each committed
41    /// sketch as a SHEET SOLID (planar face + named boundary edges + corner
42    /// vertices) via `sketch_display_payload`, no kernel-contract change.
43    pub profiles: Vec<(String, brep_kernel::SketchProfile)>,
44    /// Named axis LINES this run produced `(axis name, line)`, in run order — a
45    /// SKETCH publishes one per line geometry (construction included). Surfaced
46    /// here (exactly like [`Self::frames`]/[`Self::profiles`]) so the engine can
47    /// resolve a revolve/sweep `axis` reference to a world line at annotation-build
48    /// time, fully headless. `#[serde(default)]` keeps older serialized reports
49    /// (pre-`axes`) deserializing cleanly across the worker boundary.
50    #[serde(default)]
51    pub axes: Vec<(String, brep_kernel::Axis)>,
52    /// Named PATH chains this run produced `(path name, curves)`, in run order — a
53    /// SKETCH publishes its whole ordered chain under `{id}` and EACH model segment
54    /// under `{id}:G{gid}`. Surfaced here (exactly like [`Self::profiles`]) so the
55    /// engine can draw a committed sketch's OPEN geometry: an open chain closes no
56    /// region, so it publishes no profile, and before this the sheet builder had
57    /// nothing to draw it from — an open sketch was invisible in 3D. `#[serde(default)]`
58    /// keeps older serialized reports (pre-`paths`) deserializing cleanly across the
59    /// worker boundary.
60    #[serde(default)]
61    pub paths: Vec<(String, Vec<brep_kernel::NurbsCurve>)>,
62    /// Named world POINTS this run produced `(point name, point)`, in run order —
63    /// a SKETCH publishes every solved point under `{id}:P{pid}` with its
64    /// construction flag. Surfaced here (exactly like [`Self::paths`]) so the
65    /// engine can draw a committed sketch's STANDALONE points: a points-only
66    /// sketch (a hole-placement sketch) has no segment and no profile, so before
67    /// this the sheet builder had nothing to draw it from — it was invisible and
68    /// unpickable in 3D. `#[serde(default)]` keeps older serialized reports
69    /// (pre-`points`) deserializing cleanly across the worker boundary.
70    #[serde(default)]
71    pub points: Vec<(String, brep_kernel::ScenePoint)>,
72    /// The wire-harness tail's routing report (endpoints, segments, one route
73    /// per connection, bundles). Read RUNNER-SIDE like everything else here —
74    /// the tail ran on the runner's thread — and shipped so the harness panel
75    /// reads it off the applied run. `#[serde(default)]` keeps older serialized
76    /// reports deserializing across the worker boundary.
77    #[serde(default)]
78    pub wire_harness: Option<brep_kernel::WireHarnessReport>,
79    /// The PMI tail's resolution of every view's annotations (`None` when the
80    /// document carries no `pmi` block).
81    #[serde(default)]
82    pub pmi: Option<brep_kernel::PmiReport>,
83}
84
85/// Execute a serialized `HistoryRequest` (the `execute_history_json` request
86/// shape — a saved part file parses as one) and build the display scene from
87/// the final resident solids.
88pub fn scene_from_history_json(
89    request_json: &str,
90) -> Result<(RenderScene, SceneBuildReport), String> {
91    let request: HistoryRequest = serde_json::from_str(request_json)
92        .map_err(|error| format!("history request parse: {error}"))?;
93    scene_from_history(&request)
94}
95
96/// Typed-request variant of [`scene_from_history_json`].
97pub fn scene_from_history(
98    request: &HistoryRequest,
99) -> Result<(RenderScene, SceneBuildReport), String> {
100    let mut scene = RenderScene::new();
101    let report = update_scene_from_history(&mut scene, request)?;
102    Ok((scene, report))
103}
104
105/// The fold of a history run into an ordered scene layout: each entry is the
106/// solid's final name, its resident handle, and whether the feature that
107/// produced it REPLAYED from the incremental cache (R10 — a reused solid is the
108/// same resident geometry, so its display can be kept verbatim and its GPU
109/// buffers reused).
110struct SceneLayout {
111    order: Vec<String>,
112    handles: std::collections::HashMap<String, u32>,
113    reused: std::collections::HashSet<String>,
114    /// `name -> creating-feature id` of the FINAL resident SOLIDS (last writer
115    /// wins; a `removed` name drops its entry) — the eager provenance the runner
116    /// ships so the main thread never has to re-run the history to answer
117    /// "what feature produced this SOLID?". Matches the resident semantics of the
118    /// old `resident_handles_and_creators`.
119    creators: std::collections::HashMap<String, String>,
120    /// `face/edge NAME -> ORIGINATING feature id` — the FIRST feature (timeline
121    /// order) to emit each face/edge name, i.e. the entity's TRUE origin (the
122    /// feature that gave it its name). Unlike `creators` this is FIRST-writer-wins
123    /// and is NEVER pruned on `removed`: a boolean removes its target solid and
124    /// re-adds the same solid name carrying mostly the same face names, so pruning
125    /// then re-adding would reset those origins to the boolean feature — the same
126    /// last-writer bug one level down. Accepted edge case: a fully-deleted solid
127    /// whose name later recurs on unrelated geometry keeps its old origin — but
128    /// under this app's DETERMINISTIC naming a recurring name is the same
129    /// conceptual entity, and the whole map is rebuilt every run, so it can never
130    /// point at a feature that was deleted from the history.
131    entity_origin: std::collections::HashMap<String, String>,
132}
133
134fn fold_history(result: &brep_kernel::HistoryResult, report: &mut SceneBuildReport) -> SceneLayout {
135    // Removals first (a boolean result reuses a removed target's name), then
136    // additions, insertion-ordered — mirrors SceneMap::apply.
137    let mut order: Vec<String> = Vec::new();
138    let mut handles: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
139    let mut reused: std::collections::HashSet<String> = std::collections::HashSet::new();
140    let mut creators: std::collections::HashMap<String, String> = std::collections::HashMap::new();
141    let mut entity_origin: std::collections::HashMap<String, String> =
142        std::collections::HashMap::new();
143    for (index, feature) in result.results.iter().enumerate() {
144        // A feature whose `inputParams` carry no `id` still has to be nameable in
145        // the report, or its errors read as `": <message>"` and point at nothing.
146        // Its POSITION is the only handle it has, and the one `feature_delete`
147        // takes to remove it again.
148        let label = if feature.id.is_empty() { format!("feature[{index}]") } else { feature.id.clone() };
149        for removed in &feature.removed {
150            if handles.remove(removed).is_some() {
151                order.retain(|name| name != removed);
152            }
153            reused.remove(removed);
154            creators.remove(removed);
155            // NOTE: `entity_origin` is deliberately NOT pruned here — see its doc on
156            // `SceneLayout`. First-writer-with-no-pruning is the whole point.
157        }
158        for added in &feature.added {
159            if handles.insert(added.name.clone(), added.handle).is_none() {
160                order.push(added.name.clone());
161            }
162            creators.insert(added.name.clone(), feature.id.clone());
163            // The FIRST feature to emit a given face/edge name IS its origin (this
164            // loop is in timeline order). `entry(..).or_insert_with` keeps that
165            // first writer — using `insert` here would be last-writer and silently
166            // reproduce the exact bug this map exists to fix.
167            for (_, name) in &added.face_names {
168                entity_origin
169                    .entry(name.clone())
170                    .or_insert_with(|| feature.id.clone());
171            }
172            for (_, name) in &added.edge_names {
173                entity_origin
174                    .entry(name.clone())
175                    .or_insert_with(|| feature.id.clone());
176            }
177            // A solid displays as reused only when its whole producing feature
178            // replayed unchanged; a re-run feature re-tessellates.
179            if feature.reused {
180                reused.insert(added.name.clone());
181            } else {
182                reused.remove(&added.name);
183            }
184        }
185        if let Some(error) = &feature.error {
186            report.feature_errors.push(format!("{label}: {error}"));
187        }
188        for name in &feature.unresolved {
189            report.unresolved.push(format!("{label}: {name}"));
190        }
191        // The feature's output solid name(s) — the history tree's Outputs node.
192        report.feature_outputs.push((
193            feature.id.clone(),
194            feature.added.iter().map(|a| a.name.clone()).collect(),
195        ));
196        // The named plane frames this feature resolved (DATUM three / PLANE one /
197        // SKETCH its own). Carried straight through so the engine can display the
198        // construction datum/plane frames (it filters to the D/P producers).
199        for (name, frame) in &feature.frames {
200            report.frames.push((name.clone(), *frame));
201        }
202        // The solved sketch profile (SKETCH features publish one under `{id}`) —
203        // carried straight through so the engine can synthesize its sheet solid.
204        for (name, profile) in &feature.profiles {
205            report.profiles.push((name.clone(), profile.clone()));
206        }
207        // The named axis lines this feature published (a SKETCH emits one per line
208        // geometry) — carried through so the engine can resolve a revolve `axis`
209        // reference to a world line for the angle gizmo.
210        for (name, axis) in &feature.axes {
211            report.axes.push((name.clone(), *axis));
212        }
213        // The named path chains this feature published (a SKETCH emits its whole
214        // chain under `{id}` and every model segment under `{id}:G{gid}`) — carried
215        // through so the engine can draw the segments no closed profile covers.
216        for (name, curves) in &feature.paths {
217            report.paths.push((name.clone(), curves.clone()));
218        }
219        // The named world points this feature published (a SKETCH emits every
220        // solved point under `{id}:P{pid}`) — carried through so the engine can
221        // draw a sketch's standalone points, which no segment covers.
222        for (name, point) in &feature.points {
223            report.points.push((name.clone(), *point));
224        }
225    }
226    // Per-feature timing rides the kernel result straight through.
227    report.feature_timings = result.timings.clone();
228    // The wire-harness routing report rides through the same way; its bundle
229    // solids arrived above as the appended `WireHarness` result's `added`.
230    report.wire_harness = result.wire_harness.clone();
231    report.pmi = result.pmi.clone();
232    SceneLayout { order, handles, reused, creators, entity_origin }
233}
234
235/// Executes history without accessing the render scene and emits display deltas.
236/// Tracks resident handles so unchanged solids retain their meshes and GPU
237/// buffers when the main thread applies the resulting [`RunOutput`].
238pub struct SceneRunner {
239    /// Last emitted handle per solid name. Handles are never recycled, so a
240    /// matching handle identifies a cache replay whose display can be reused.
241    last_sent: HashMap<String, u32>,
242    /// Baseline tessellation LOD. A change clears `last_sent` so even unchanged
243    /// handles receive meshes built with the new chord tolerance.
244    last_lod: f64,
245    /// Revision installed via `Command::SetPartsLibrary`. Runs with a different
246    /// revision are refused; `reset` clears this alongside the kernel store.
247    pub(crate) parts_library_revision: Option<u64>,
248    /// Names in the last library install. A missing installed part triggers a
249    /// library reload; a name never installed proceeds to the feature's normal
250    /// unresolved-reference error, avoiding an endless reload loop.
251    pub(crate) parts_library_names: std::collections::BTreeSet<String>,
252}
253
254/// The delta a [`SceneRunner::run`] produces: the displayed KERNEL solids IN
255/// ORDER plus the run's [`SceneBuildReport`]. The applier walks `snapshot` in
256/// order, reusing or replacing each entry (see the field docs).
257#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
258pub struct RunOutput {
259    /// The displayed KERNEL solids IN ORDER. `display: Some` = freshly tessellated
260    /// (new, or the handle changed since last emit); `None` = UNCHANGED — the
261    /// applier keeps its existing [`SolidDisplay`] for this name, whose
262    /// `source_handle` equals `handle`.
263    pub snapshot: Vec<(String /* name */, u32 /* handle */, Option<SolidDisplay>)>,
264    pub report: SceneBuildReport,
265    /// Eager PROVENANCE: `name -> creating-feature id` for the run's FINAL resident
266    /// SOLIDS (same last-writer-wins fold as the display snapshot). Shipped WITH the
267    /// run so the main thread can answer SOLID provenance without a cold
268    /// `execute_history` — critical once the run lives on a background thread and the
269    /// main-side registry is cold.
270    pub provenance: Vec<(String, String)>,
271    /// Eager ENTITY ORIGIN: `face/edge NAME -> ORIGINATING feature id` (FIRST writer
272    /// in timeline order, never pruned — see `SceneLayout::entity_origin`). Shipped
273    /// beside `provenance` so `creating_feature` can answer "which feature gave this
274    /// face/edge its name?" (the "Edit owning feature" context action + the Info
275    /// tab's `creatingFeature`) with no cold re-run. Crosses the background-worker
276    /// seam, so the serde round-trip test asserts it survives.
277    pub entity_origin: Vec<(String, String)>,
278    /// Assembly-solver POSE write-backs from this run's constraint tail:
279    /// `(component feature id, {translate, rotateEulerDeg})` — read RUNNER-SIDE
280    /// (the kernel assembly session is thread-local to the thread/worker that ran
281    /// `execute_history`, so only the runner can see it) and shipped here so the
282    /// engine folds them into the owning ACOMP features' `inputParams` (the
283    /// pose-authority contract, build-spec §6 step 4). Empty on a no-motion
284    /// solve — a satisfied assembly must not churn feature fingerprints.
285    /// `#[serde(default)]` keeps pre-assembly serialized replies deserializing.
286    #[serde(default)]
287    pub assembly_poses: Vec<(String, serde_json::Value)>,
288    /// `isFixed` write-backs riding the same fold (the Fixed-constraint lane
289    /// grounds a component); shape mirrors [`Self::assembly_poses`].
290    #[serde(default)]
291    pub assembly_fixed: Vec<(String, bool)>,
292    /// Names of solids the solve re-posed IN PLACE this run (the display seam:
293    /// their producing feature may have replayed `reused`, yet their geometry
294    /// moved). The runner already defeated the handle-unchanged reuse fast path
295    /// for these (their snapshot entries arrive `Some`, freshly tessellated);
296    /// shipped for observability/tests. Empty on zero-mate/no-motion runs.
297    #[serde(default)]
298    pub moved_solids: Vec<String>,
299    /// IMPORTED COLOURS: `entity name -> "#RRGGBB"` for every solid/face this run
300    /// left a `color` scene-metadata record on (STEP presentation entities, read
301    /// by `brep_kernel::io/appearance.rs` and stamped by IMPORT3D).
302    ///
303    /// Read RUNNER-SIDE for the same reason as `assembly_poses`: the kernel's
304    /// scene-metadata store is thread-local to whoever ran `execute_history`, so
305    /// a main-thread read under a background runner sees nothing. The applier
306    /// folds these into the engine's own [`crate::metadata::MetadataStore`]
307    /// WITHOUT overwriting, so the Info window shows an imported colour and a
308    /// user's edit of it still wins.
309    ///
310    /// Filtered to the names this run actually produced, so a colour left in the
311    /// (never-cleared) kernel store by a previous document cannot bleed into
312    /// this one.
313    #[serde(default)]
314    pub imported_colors: Vec<(String, String)>,
315}
316
317impl SceneRunner {
318    pub fn new() -> Self {
319        Self {
320            last_sent: HashMap::new(),
321            last_lod: 1.0,
322            parts_library_revision: None,
323            parts_library_names: std::collections::BTreeSet::new(),
324        }
325    }
326
327    /// Reset the delta baseline (call on a wholesale document switch so the next
328    /// run is a full rebuild — no stale reuse across unrelated models).
329    pub fn reset(&mut self) {
330        self.last_sent.clear();
331        // A reset always accompanies the `clear_history_cache` that empties
332        // this side's parts library, so forget what was installed — the next
333        // run's stamp will not match and the library is re-sent.
334        self.parts_library_revision = None;
335        self.parts_library_names.clear();
336    }
337
338    /// Execute `request` and fold it into an ordered delta snapshot (does NOT
339    /// touch any scene). For each displayed kernel solid in order: if the handle
340    /// is unchanged since the last emit, emit `(name, handle, None)` (the applier
341    /// keeps its existing display); otherwise (re-)tessellate the resident handle
342    /// and emit `(name, handle, Some(display))`. A display-payload error is
343    /// recorded in `report.display_errors` and the entry is SKIPPED — and NOT
344    /// recorded in the new baseline, so a later successful run re-tessellates it.
345    ///
346    ///
347    /// Colours are NOT applied here. A display arrives colourless and the main
348    /// side paints it from the metadata store
349    /// ([`EngineState::sync_colors_from_metadata`](crate::engine_state::EngineState::sync_colors_from_metadata)),
350    /// which is the only colour authority — a runner may be a background thread
351    /// or worker with no view of that store.
352    pub fn run(&mut self, request: &HistoryRequest) -> RunOutput {
353        self.run_observed(request, &mut |_| true)
354    }
355
356    /// [`Self::run`] with a progress observer: `observe` is called before every
357    /// feature the kernel actually executes (see
358    /// [`brep_kernel::execute_history_observed`]); returning `false` stops the
359    /// run at that boundary, and the partial result is folded like any other.
360    pub fn run_observed(
361        &mut self,
362        request: &HistoryRequest,
363        observe: &mut dyn FnMut(brep_kernel::HistoryProgress<'_>) -> bool,
364    ) -> RunOutput {
365        let result = brep_kernel::execute_history_observed(request, observe);
366        let mut report = SceneBuildReport::default();
367        let layout = fold_history(&result, &mut report);
368
369        // --- assembly pose-authority read (RUNNER-SIDE, build-spec §6/§13) ----
370        // The kernel assembly session (constraint solve tail) is THREAD-LOCAL to
371        // whoever ran `execute_history` — i.e. this thread/worker — so the pose
372        // and isFixed write-backs must be read HERE and shipped in the RunOutput;
373        // a main-thread read under a background runner would see a cold session.
374        let pose_updates: serde_json::Value =
375            serde_json::from_str(&brep_kernel::assembly_pose_updates_json())
376                .unwrap_or(serde_json::Value::Null);
377        let assembly_poses: Vec<(String, serde_json::Value)> = pose_updates["poses"]
378            .as_object()
379            .map(|map| map.iter().map(|(id, pose)| (id.clone(), pose.clone())).collect())
380            .unwrap_or_default();
381        let assembly_fixed: Vec<(String, bool)> = pose_updates["isFixed"]
382            .as_object()
383            .map(|map| {
384                map.iter()
385                    .filter_map(|(id, flag)| flag.as_bool().map(|b| (id.clone(), b)))
386                    .collect()
387            })
388            .unwrap_or_default();
389        // The display seam: solids the solve re-posed IN PLACE keep their resident
390        // handle, so the handle-unchanged reuse fast path below would wrongly skip
391        // re-tessellating them even though their geometry moved. Drop them from
392        // the baseline so they re-emit fresh displays. The `movedSolids` key is
393        // ABSENT on zero-mate reports — tolerated (empty).
394        let dof: serde_json::Value = serde_json::from_str(&brep_kernel::assembly_dof_json())
395            .unwrap_or(serde_json::Value::Null);
396        let moved_solids: Vec<String> = dof["movedSolids"]
397            .as_array()
398            .map(|items| {
399                items
400                    .iter()
401                    .filter_map(|item| item["name"].as_str().map(String::from))
402                    .collect()
403            })
404            .unwrap_or_default();
405        for name in &moved_solids {
406            self.last_sent.remove(name);
407        }
408
409        // Display LOD: a finite, positive factor (garbage from a hand-edited saved
410        // file → the "Normal" 1.0, since chord = extent·1.5e-3·lod and a 0/NaN lod
411        // would zero the tolerance → runaway refinement). A change since the last
412        // run means every resident mesh must re-tessellate at the new chord even
413        // though its handle is unchanged, so drop the reuse baseline.
414        let lod = if request.display_lod.is_finite() && request.display_lod > 0.0 {
415            request.display_lod
416        } else {
417            1.0
418        };
419        if lod != self.last_lod {
420            self.last_sent.clear();
421            self.last_lod = lod;
422        }
423
424        let mut snapshot: Vec<(String, u32, Option<SolidDisplay>)> =
425            Vec::with_capacity(layout.order.len());
426        let mut next_sent: HashMap<String, u32> = HashMap::with_capacity(layout.order.len());
427
428        for name in &layout.order {
429            let handle = layout.handles[name];
430            if self.last_sent.get(name) == Some(&handle) {
431                // Handle unchanged since last emit ⇒ same resident geometry ⇒ the
432                // applier keeps its existing display. This is EXACTLY the old
433                // `reused && source_handle == handle` fast path (monotonic handles):
434                // the canary below verifies the implication holds.
435                debug_assert!(
436                    layout.reused.contains(name),
437                    "handle unchanged must imply reused (monotonic handles): {name}"
438                );
439                snapshot.push((name.clone(), handle, None));
440                next_sent.insert(name.clone(), handle);
441                continue;
442            }
443            match display_payload_handle_native(handle, lod) {
444                Ok(payload) => {
445                    let mut solid = solid_display_from_payload(name, payload);
446                    solid.source_handle = handle;
447                    // Runner thread → the SheetTree thread-local is warm here; stamp
448                    // the sheet-metal marker so the UI thread reads it off the scene
449                    // (never calling the thread-local from a cold UI thread).
450                    solid.is_sheet_metal = brep_kernel::is_sheet_metal_handle(handle);
451                    snapshot.push((name.clone(), handle, Some(solid)));
452                    next_sent.insert(name.clone(), handle);
453                }
454                // A tessellation failure skips the solid (non-fatal) and does NOT
455                // poison the baseline — dropping it lets a later successful run
456                // re-tessellate from scratch.
457                Err(error) => report.display_errors.push(format!("{name}: {error}")),
458            }
459        }
460
461        self.last_sent = next_sent;
462        // Eager provenance for the run's final resident solids (the applier stores
463        // this map main-side so per-frame provenance queries never re-run history).
464        let provenance: Vec<(String, String)> = layout
465            .creators
466            .iter()
467            .map(|(name, id)| (name.clone(), id.clone()))
468            .collect();
469        // Eager entity origin (face/edge NAME -> originating feature id) rides
470        // alongside, so face/edge provenance survives the off-thread seam too.
471        let entity_origin: Vec<(String, String)> = layout
472            .entity_origin
473            .iter()
474            .map(|(name, id)| (name.clone(), id.clone()))
475            .collect();
476        // Imported colours, read HERE for the thread-local reason above and
477        // filtered to this run's own entities: the solids it displays plus the
478        // faces/edges it named. The kernel store is never cleared between
479        // documents, so an unfiltered ship could hand the applier a colour that
480        // belongs to a model the user closed.
481        let imported_colors: Vec<(String, String)> = serde_json::from_str::<serde_json::Value>(
482            &brep_kernel::scene_metadata_colors_json(),
483        )
484        .ok()
485        .and_then(|value| value.as_object().cloned())
486        .map(|map| {
487            map.into_iter()
488                .filter(|(name, _)| {
489                    layout.handles.contains_key(name) || layout.entity_origin.contains_key(name)
490                })
491                .filter_map(|(name, hex)| hex.as_str().map(|hex| (name, hex.to_string())))
492                .collect()
493        })
494        .unwrap_or_default();
495
496        RunOutput {
497            snapshot,
498            report,
499            provenance,
500            entity_origin,
501            assembly_poses,
502            assembly_fixed,
503            moved_solids,
504            imported_colors,
505        }
506    }
507
508    /// The resident handle last EMITTED for `name` (the delta baseline), or `None`
509    /// if the runner has not emitted a solid of that name. The measurement-query
510    /// path resolves an object's owning-solid handle through this so the query runs
511    /// against the SAME resident geometry the last run displayed (on the runner's
512    /// own thread, whose registry is warm from that run).
513    pub fn handle_of(&self, name: &str) -> Option<u32> {
514        self.last_sent.get(name).copied()
515    }
516}
517
518impl Default for SceneRunner {
519    fn default() -> Self {
520        Self::new()
521    }
522}
523
524/// Execute the history and reconcile `scene` in place (R10 incremental update):
525/// reused solids already present keep their [`SolidDisplay`] verbatim (stable
526/// `revision` ⇒ the renderer reuses their GPU buffers); everything else is
527/// (re-)tessellated from the resident handle; departed solids are dropped.
528///
529/// Implemented on the M1 seam: a throwaway [`SceneRunner`] primed from the
530/// CURRENT scene (`name → source_handle` of what is displayed) reproduces the
531/// pre-seam reuse in a single synchronous call, and its [`RunOutput`] delta is
532/// applied back to `scene` by MOVING existing displays out (via
533/// [`RenderScene::drain`]) so a reused solid's mesh is never cloned.
534pub fn update_scene_from_history(
535    scene: &mut RenderScene,
536    request: &HistoryRequest,
537) -> Result<SceneBuildReport, String> {
538    let mut runner = SceneRunner::new();
539    runner.last_sent = scene
540        .solids()
541        .iter()
542        .map(|solid| (solid.name.clone(), solid.source_handle))
543        .collect();
544    let output = runner.run(request);
545
546    // Move the current displays out, then reinsert in snapshot ORDER: a fresh
547    // entry replaces, an UNCHANGED entry reuses the moved-out display (whose
548    // `source_handle` equals the run's handle — guaranteed by the reuse
549    // invariant). Leftovers (departed names) are dropped.
550    let mut kept: HashMap<String, SolidDisplay> = scene
551        .drain()
552        .into_iter()
553        .map(|solid| (solid.name.clone(), solid))
554        .collect();
555    for (name, _handle, maybe) in output.snapshot {
556        match maybe {
557            Some(display) => scene.insert_solid(display),
558            None => scene.insert_solid(
559                kept.remove(&name).expect("keep target present"),
560            ),
561        }
562    }
563    Ok(output.report)
564}
565
566/// Execute `request` and return the FINAL resident solids as `(name, handle)` in
567/// display order — the export lane (STEP/STL) needs the current solids' resident
568/// handles, which the scene build does not itself retain. Run right after a scene
569/// build (warm incremental cache) this replays the cached features, so the
570/// handles it returns are the very ones the displayed scene was built from; run
571/// cold it re-executes and registers fresh (still-valid) handles. Either way the
572/// handles are live in the thread-local registry when this returns, ready to
573/// hand to `brep_kernel::export_step_handles`.
574pub fn resident_solid_handles(request: &HistoryRequest) -> Vec<(String, u32)> {
575    let result = execute_history(request);
576    let mut report = SceneBuildReport::default();
577    let layout = fold_history(&result, &mut report);
578    layout
579        .order
580        .iter()
581        .map(|name| (name.clone(), layout.handles[name]))
582        .collect()
583}
584
585// BREP private tests: d9141f09a10d477b