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_profile_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}
53
54/// Execute a serialized `HistoryRequest` (the `execute_history_json` request
55/// shape — a saved part file parses as one) and build the display scene from
56/// the final resident solids.
57pub fn scene_from_history_json(
58    request_json: &str,
59) -> Result<(RenderScene, SceneBuildReport), String> {
60    let request: HistoryRequest = serde_json::from_str(request_json)
61        .map_err(|error| format!("history request parse: {error}"))?;
62    scene_from_history(&request)
63}
64
65/// Typed-request variant of [`scene_from_history_json`].
66pub fn scene_from_history(
67    request: &HistoryRequest,
68) -> Result<(RenderScene, SceneBuildReport), String> {
69    let mut scene = RenderScene::new();
70    let report = update_scene_from_history(&mut scene, request, None)?;
71    Ok((scene, report))
72}
73
74/// The fold of a history run into an ordered scene layout: each entry is the
75/// solid's final name, its resident handle, and whether the feature that
76/// produced it REPLAYED from the incremental cache (R10 — a reused solid is the
77/// same resident geometry, so its display can be kept verbatim and its GPU
78/// buffers reused).
79struct SceneLayout {
80    order: Vec<String>,
81    handles: std::collections::HashMap<String, u32>,
82    reused: std::collections::HashSet<String>,
83    /// `name -> creating-feature id` of the FINAL resident solids (last writer
84    /// wins; a `removed` name drops its entry) — the eager provenance the runner
85    /// ships so the main thread never has to re-run the history to answer
86    /// "what feature produced this object?". Matches the resident semantics of the
87    /// old `resident_handles_and_creators`.
88    creators: std::collections::HashMap<String, String>,
89}
90
91fn fold_history(result: &brep_kernel::HistoryResult, report: &mut SceneBuildReport) -> SceneLayout {
92    // Removals first (a boolean result reuses a removed target's name), then
93    // additions, insertion-ordered — mirrors SceneMap::apply.
94    let mut order: Vec<String> = Vec::new();
95    let mut handles: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
96    let mut reused: std::collections::HashSet<String> = std::collections::HashSet::new();
97    let mut creators: std::collections::HashMap<String, String> = std::collections::HashMap::new();
98    for feature in &result.results {
99        for removed in &feature.removed {
100            if handles.remove(removed).is_some() {
101                order.retain(|name| name != removed);
102            }
103            reused.remove(removed);
104            creators.remove(removed);
105        }
106        for added in &feature.added {
107            if handles.insert(added.name.clone(), added.handle).is_none() {
108                order.push(added.name.clone());
109            }
110            creators.insert(added.name.clone(), feature.id.clone());
111            // A solid displays as reused only when its whole producing feature
112            // replayed unchanged; a re-run feature re-tessellates.
113            if feature.reused {
114                reused.insert(added.name.clone());
115            } else {
116                reused.remove(&added.name);
117            }
118        }
119        if let Some(error) = &feature.error {
120            report.feature_errors.push(format!("{}: {error}", feature.id));
121        }
122        for name in &feature.unresolved {
123            report.unresolved.push(format!("{}: {name}", feature.id));
124        }
125        // The feature's output solid name(s) — the history tree's Outputs node.
126        report.feature_outputs.push((
127            feature.id.clone(),
128            feature.added.iter().map(|a| a.name.clone()).collect(),
129        ));
130        // The named plane frames this feature resolved (DATUM three / PLANE one /
131        // SKETCH its own). Carried straight through so the engine can display the
132        // construction datum/plane frames (it filters to the D/P producers).
133        for (name, frame) in &feature.frames {
134            report.frames.push((name.clone(), *frame));
135        }
136        // The solved sketch profile (SKETCH features publish one under `{id}`) —
137        // carried straight through so the engine can synthesize its sheet solid.
138        for (name, profile) in &feature.profiles {
139            report.profiles.push((name.clone(), profile.clone()));
140        }
141        // The named axis lines this feature published (a SKETCH emits one per line
142        // geometry) — carried through so the engine can resolve a revolve `axis`
143        // reference to a world line for the angle gizmo.
144        for (name, axis) in &feature.axes {
145            report.axes.push((name.clone(), *axis));
146        }
147    }
148    // Per-feature timing rides the kernel result straight through.
149    report.feature_timings = result.timings.clone();
150    SceneLayout { order, handles, reused, creators }
151}
152
153/// A stateful, SCENE-FREE history run that emits a DELTA snapshot instead of
154/// mutating a scene — the M1 seam of the off-thread history runner. It remembers,
155/// in [`Self::last_sent`], the resident handle it last EMITTED per solid name, so
156/// a rerun can tell the applier which displays are UNCHANGED (skip re-tessellating
157/// + keep their GPU buffers) vs. which are new/rebound (freshly tessellated).
158///
159/// Being scene-free is the point: a later milestone runs this on a background
160/// thread / worker with no access to the render scene, then ships the
161/// [`RunOutput`] delta back to the main thread to apply. In M1 the run is
162/// immediately applied on the same thread (see `EngineState::apply_run_output`),
163/// so behavior is byte-identical to the pre-seam in-place reconcile.
164pub struct SceneRunner {
165    /// `name -> handle` of what was last EMITTED (the delta baseline). Handles are
166    /// monotonic and never recycled (the resident registry's counter only
167    /// increments, and `clear_history_cache` frees solids without resetting it),
168    /// so "handle unchanged since last emit" is EXACTLY the old
169    /// `reused && source_handle matches` reuse condition — a re-executed feature
170    /// always registers a NEW handle, so a matching handle proves a cache replay.
171    last_sent: HashMap<String, u32>,
172    /// The display LOD factor the current baseline was tessellated at. When a run
173    /// arrives at a DIFFERENT lod (the user moved the "LOD factor" slider) every
174    /// resident mesh must be rebuilt at the new chord tolerance even though its
175    /// handle is unchanged — so a lod change drops `last_sent` to defeat the
176    /// handle-unchanged reuse fast path. Init `1.0` (the "Normal" default) so a
177    /// first run at the default lod does NOT spuriously invalidate an empty
178    /// baseline's peers.
179    last_lod: f64,
180}
181
182/// The delta a [`SceneRunner::run`] produces: the displayed KERNEL solids IN
183/// ORDER plus the run's [`SceneBuildReport`]. The applier walks `snapshot` in
184/// order, reusing or replacing each entry (see the field docs).
185#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
186pub struct RunOutput {
187    /// The displayed KERNEL solids IN ORDER. `display: Some` = freshly tessellated
188    /// (new, or the handle changed since last emit); `None` = UNCHANGED — the
189    /// applier keeps its existing [`SolidDisplay`] for this name, whose
190    /// `source_handle` equals `handle`.
191    pub snapshot: Vec<(String /* name */, u32 /* handle */, Option<SolidDisplay>)>,
192    pub report: SceneBuildReport,
193    /// Eager PROVENANCE: `name -> creating-feature id` for the run's FINAL resident
194    /// solids (same last-writer-wins fold as the display snapshot). Shipped WITH the
195    /// run so the main thread can answer object provenance (`creating_feature`, the
196    /// Info tab's `creatingFeature`) without a cold `execute_history` — critical once
197    /// the run lives on a background thread and the main-side registry is cold.
198    pub provenance: Vec<(String, String)>,
199}
200
201impl SceneRunner {
202    pub fn new() -> Self {
203        Self { last_sent: HashMap::new(), last_lod: 1.0 }
204    }
205
206    /// Reset the delta baseline (call on a wholesale document switch so the next
207    /// run is a full rebuild — no stale reuse across unrelated models).
208    pub fn reset(&mut self) {
209        self.last_sent.clear();
210    }
211
212    /// Execute `request` and fold it into an ordered delta snapshot (does NOT
213    /// touch any scene). For each displayed kernel solid in order: if the handle
214    /// is unchanged since the last emit, emit `(name, handle, None)` (the applier
215    /// keeps its existing display); otherwise (re-)tessellate the resident handle
216    /// and emit `(name, handle, Some(display))`. A display-payload error is
217    /// recorded in `report.display_errors` and the entry is SKIPPED — and NOT
218    /// recorded in the new baseline, so a later successful run re-tessellates it.
219    ///
220    /// `color_overrides` is an optional `name → sRGB [0..1;3]` map (the metadata
221    /// color layer); applied to freshly built solids only (reused ones keep it).
222    pub fn run(
223        &mut self,
224        request: &HistoryRequest,
225        color_overrides: Option<&HashMap<String, [f32; 3]>>,
226    ) -> RunOutput {
227        let result = execute_history(request);
228        let mut report = SceneBuildReport::default();
229        let layout = fold_history(&result, &mut report);
230
231        // Display LOD: a finite, positive factor (garbage from a hand-edited saved
232        // file → the "Normal" 1.0, since chord = extent·1.5e-3·lod and a 0/NaN lod
233        // would zero the tolerance → runaway refinement). A change since the last
234        // run means every resident mesh must re-tessellate at the new chord even
235        // though its handle is unchanged, so drop the reuse baseline.
236        let lod = if request.display_lod.is_finite() && request.display_lod > 0.0 {
237            request.display_lod
238        } else {
239            1.0
240        };
241        if lod != self.last_lod {
242            self.last_sent.clear();
243            self.last_lod = lod;
244        }
245
246        let mut snapshot: Vec<(String, u32, Option<SolidDisplay>)> =
247            Vec::with_capacity(layout.order.len());
248        let mut next_sent: HashMap<String, u32> = HashMap::with_capacity(layout.order.len());
249
250        for name in &layout.order {
251            let handle = layout.handles[name];
252            if self.last_sent.get(name) == Some(&handle) {
253                // Handle unchanged since last emit ⇒ same resident geometry ⇒ the
254                // applier keeps its existing display. This is EXACTLY the old
255                // `reused && source_handle == handle` fast path (monotonic handles):
256                // the canary below verifies the implication holds.
257                debug_assert!(
258                    layout.reused.contains(name),
259                    "handle unchanged must imply reused (monotonic handles): {name}"
260                );
261                snapshot.push((name.clone(), handle, None));
262                next_sent.insert(name.clone(), handle);
263                continue;
264            }
265            match display_payload_handle_native(handle, lod) {
266                Ok(payload) => {
267                    let mut solid = solid_display_from_payload(name, payload);
268                    solid.source_handle = handle;
269                    if let Some(overrides) = color_overrides {
270                        solid.color_override = overrides.get(name).copied();
271                    }
272                    snapshot.push((name.clone(), handle, Some(solid)));
273                    next_sent.insert(name.clone(), handle);
274                }
275                // A tessellation failure skips the solid (non-fatal) and does NOT
276                // poison the baseline — dropping it lets a later successful run
277                // re-tessellate from scratch.
278                Err(error) => report.display_errors.push(format!("{name}: {error}")),
279            }
280        }
281
282        self.last_sent = next_sent;
283        // Eager provenance for the run's final resident solids (the applier stores
284        // this map main-side so per-frame provenance queries never re-run history).
285        let provenance: Vec<(String, String)> = layout
286            .creators
287            .iter()
288            .map(|(name, id)| (name.clone(), id.clone()))
289            .collect();
290        RunOutput { snapshot, report, provenance }
291    }
292
293    /// The resident handle last EMITTED for `name` (the delta baseline), or `None`
294    /// if the runner has not emitted a solid of that name. The measurement-query
295    /// path resolves an object's owning-solid handle through this so the query runs
296    /// against the SAME resident geometry the last run displayed (on the runner's
297    /// own thread, whose registry is warm from that run).
298    pub fn handle_of(&self, name: &str) -> Option<u32> {
299        self.last_sent.get(name).copied()
300    }
301}
302
303impl Default for SceneRunner {
304    fn default() -> Self {
305        Self::new()
306    }
307}
308
309/// Execute the history and reconcile `scene` in place (R10 incremental update):
310/// reused solids already present keep their [`SolidDisplay`] verbatim (stable
311/// `revision` ⇒ the renderer reuses their GPU buffers); everything else is
312/// (re-)tessellated from the resident handle; departed solids are dropped.
313///
314/// `color_overrides` is an optional `name → sRGB [0..1;3]` map (the metadata
315/// color layer); applied to freshly built solids so the renderer picks it up.
316///
317/// Implemented on the M1 seam: a throwaway [`SceneRunner`] primed from the
318/// CURRENT scene (`name → source_handle` of what is displayed) reproduces the
319/// pre-seam reuse in a single synchronous call, and its [`RunOutput`] delta is
320/// applied back to `scene` by MOVING existing displays out (via
321/// [`RenderScene::drain`]) so a reused solid's mesh is never cloned.
322pub fn update_scene_from_history(
323    scene: &mut RenderScene,
324    request: &HistoryRequest,
325    color_overrides: Option<&HashMap<String, [f32; 3]>>,
326) -> Result<SceneBuildReport, String> {
327    let mut runner = SceneRunner::new();
328    runner.last_sent = scene
329        .solids()
330        .iter()
331        .map(|solid| (solid.name.clone(), solid.source_handle))
332        .collect();
333    let output = runner.run(request, color_overrides);
334
335    // Move the current displays out, then reinsert in snapshot ORDER: a fresh
336    // entry replaces, an UNCHANGED entry reuses the moved-out display (whose
337    // `source_handle` equals the run's handle — guaranteed by the reuse
338    // invariant). Leftovers (departed names) are dropped.
339    let mut kept: HashMap<String, SolidDisplay> = scene
340        .drain()
341        .into_iter()
342        .map(|solid| (solid.name.clone(), solid))
343        .collect();
344    for (name, _handle, maybe) in output.snapshot {
345        match maybe {
346            Some(display) => scene.insert_solid(display),
347            None => scene.insert_solid(
348                kept.remove(&name).expect("keep target present"),
349            ),
350        }
351    }
352    Ok(output.report)
353}
354
355/// Execute `request` and return the FINAL resident solids as `(name, handle)` in
356/// display order — the export lane (STEP/STL) needs the current solids' resident
357/// handles, which the scene build does not itself retain. Run right after a scene
358/// build (warm incremental cache) this replays the cached features, so the
359/// handles it returns are the very ones the displayed scene was built from; run
360/// cold it re-executes and registers fresh (still-valid) handles. Either way the
361/// handles are live in the thread-local registry when this returns, ready to
362/// hand to `brep_kernel::export_step_handles`.
363pub fn resident_solid_handles(request: &HistoryRequest) -> Vec<(String, u32)> {
364    let result = execute_history(request);
365    let mut report = SceneBuildReport::default();
366    let layout = fold_history(&result, &mut report);
367    layout
368        .order
369        .iter()
370        .map(|name| (name.clone(), layout.handles[name]))
371        .collect()
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    fn cube_request(name: &str, size: f64) -> String {
379        serde_json::json!({
380            "expressions": "",
381            "configurator": {},
382            "features": [{
383                "type": "P.CU",
384                "inputParams": {
385                    "id": name,
386                    "sizeX": size, "sizeY": size, "sizeZ": size,
387                    "transform": {
388                        "position": [0.0, 0.0, 0.0],
389                        "rotationEuler": [0.0, 0.0, 0.0],
390                        "scale": [1.0, 1.0, 1.0]
391                    },
392                    "boolean": { "targets": [], "operation": "NONE" }
393                },
394                "persistentData": {}
395            }]
396        })
397        .to_string()
398    }
399
400    // A DOTTED feature id (the new `{shortName}{N}` scheme yields ids like `P.CU1`)
401    // must be safe as a downstream reference: the `.` is inert everywhere (scene-map
402    // resolution is exact HashMap lookup — only `:` and `|` are name delimiters).
403    // Prove it at RUNTIME: a boolean SUBTRACT that targets a dotted-id solid resolves
404    // (nothing lands in `report.unresolved`) and builds a scene.
405    #[test]
406    fn dotted_feature_id_resolves_as_a_boolean_reference() {
407        let request = serde_json::json!({
408            "expressions": "",
409            "configurator": {},
410            "features": [
411                {
412                    "type": "P.CU",
413                    "inputParams": {
414                        "id": "P.CU1",
415                        "sizeX": 10.0, "sizeY": 10.0, "sizeZ": 10.0,
416                        "transform": { "position": [0.0, 0.0, 0.0], "rotationEuler": [0.0, 0.0, 0.0], "scale": [1.0, 1.0, 1.0] },
417                        "boolean": { "targets": [], "operation": "NONE" }
418                    },
419                    "persistentData": {}
420                },
421                {
422                    "type": "P.CU",
423                    "inputParams": {
424                        "id": "P.CU2",
425                        "sizeX": 10.0, "sizeY": 10.0, "sizeZ": 10.0,
426                        "transform": { "position": [5.0, 0.0, 0.0], "rotationEuler": [0.0, 0.0, 0.0], "scale": [1.0, 1.0, 1.0] },
427                        // SUBTRACT referencing the DOTTED id of the first cube.
428                        "boolean": { "targets": ["P.CU1"], "operation": "SUBTRACT" }
429                    },
430                    "persistentData": {}
431                }
432            ]
433        })
434        .to_string();
435        let (scene, report) = scene_from_history_json(&request).unwrap();
436        assert!(report.feature_errors.is_empty(), "{:?}", report.feature_errors);
437        // The dotted reference `P.CU1` resolved — if `.` broke name matching it would
438        // appear here instead.
439        assert!(report.unresolved.is_empty(), "dotted ref unresolved: {:?}", report.unresolved);
440        // The subtract folded the two cubes into one resident solid.
441        assert_eq!(scene.solids().len(), 1);
442    }
443
444    #[test]
445    fn cube_history_populates_scene() {
446        let (scene, report) = scene_from_history_json(&cube_request("P.CU1", 10.0)).unwrap();
447        assert!(report.feature_errors.is_empty(), "{:?}", report.feature_errors);
448        assert!(report.display_errors.is_empty(), "{:?}", report.display_errors);
449        assert_eq!(scene.solids().len(), 1);
450        let solid = scene.solid("P.CU1").expect("solid keyed by kernel name");
451        assert_eq!(solid.faces.len(), 6);
452        // Cube tessellation: 2 triangles per face.
453        assert_eq!(solid.mesh.indices.len(), 6 * 2 * 3);
454        for face in &solid.faces {
455            assert_eq!(face.tri_count, 2, "face {} range", face.name);
456            assert!(!face.name.is_empty());
457        }
458        // 12 boundary edges, each a straight 2-point polyline, all named.
459        assert_eq!(solid.edges.len(), 12);
460        for edge in &solid.edges {
461            assert!(edge.polyline.len() >= 2);
462            assert!(!edge.name.is_empty());
463        }
464        assert_eq!(solid.vertices.len(), 8);
465        let bbox = scene.bbox();
466        assert!((bbox.size()[0] - 10.0).abs() < 1e-6);
467    }
468
469    fn sphere_request(name: &str, radius: f64, lod: f64) -> brep_kernel::HistoryRequest {
470        let mut request: brep_kernel::HistoryRequest = serde_json::from_value(serde_json::json!({
471            "expressions": "",
472            "configurator": {},
473            "features": [{
474                "type": "P.S",
475                "inputParams": {
476                    "id": name,
477                    "radius": radius,
478                    "transform": {
479                        "position": [0.0, 0.0, 0.0],
480                        "rotationEuler": [0.0, 0.0, 0.0],
481                        "scale": [1.0, 1.0, 1.0]
482                    },
483                    "boolean": { "targets": [], "operation": "NONE" }
484                },
485                "persistentData": {}
486            }]
487        }))
488        .unwrap();
489        request.display_lod = lod;
490        request
491    }
492
493    // A LOD-factor change must re-tessellate every resident mesh at the new chord
494    // tolerance EVEN THOUGH the solid's handle is unchanged (same geometry, replayed
495    // from the incremental cache) — otherwise the "LOD factor" slider does nothing.
496    #[test]
497    fn lod_change_retessellates_reused_solid_coarser() {
498        let mut runner = SceneRunner::new();
499
500        // Fine mesh at lod 0.5.
501        let fine = runner.run(&sphere_request("LodBall", 10.0, 0.5), None);
502        let (_, fine_handle, fine_display) = &fine.snapshot[0];
503        let fine_tris = fine_display.as_ref().expect("first run tessellates").mesh.indices.len();
504
505        // SAME sphere, coarser lod 4.0: the incremental cache replays the sphere so
506        // the handle is unchanged (proven below) — the ONLY reason to re-emit is the
507        // lod change, and the coarser chord must drop the triangle count.
508        let coarse = runner.run(&sphere_request("LodBall", 10.0, 4.0), None);
509        let (_, coarse_handle, coarse_display) = &coarse.snapshot[0];
510        assert_eq!(
511            coarse_handle, fine_handle,
512            "same sphere must replay to the same handle (reuse path) — else the test proves nothing"
513        );
514        let coarse_tris = coarse_display
515            .as_ref()
516            .expect("a lod change re-emits Some(display) despite the unchanged handle")
517            .mesh
518            .indices
519            .len();
520        assert!(
521            coarse_tris < fine_tris,
522            "coarser lod must shrink the mesh: fine(lod 0.5)={fine_tris} coarse(lod 4.0)={coarse_tris}"
523        );
524
525        // Re-running at the SAME lod reuses the baseline (None) — no spurious full
526        // re-tessellation on every run once the lod is stable.
527        let again = runner.run(&sphere_request("LodBall", 10.0, 4.0), None);
528        assert!(
529            again.snapshot[0].2.is_none(),
530            "an unchanged lod must reuse the display baseline (None), not re-tessellate"
531        );
532    }
533
534    #[test]
535    fn reused_history_keeps_stable_revision() {
536        let request: brep_kernel::HistoryRequest =
537            serde_json::from_str(&cube_request("Keep", 8.0)).unwrap();
538        let mut scene = RenderScene::new();
539        update_scene_from_history(&mut scene, &request, None).unwrap();
540        let first_rev = scene.solid("Keep").unwrap().revision;
541
542        // A second identical run replays from the incremental cache; the reused
543        // solid must keep its revision (so the renderer reuses its GPU buffers).
544        update_scene_from_history(&mut scene, &request, None).unwrap();
545        assert_eq!(scene.solid("Keep").unwrap().revision, first_rev);
546    }
547
548    #[test]
549    fn color_override_applies_to_fresh_solids() {
550        let request: brep_kernel::HistoryRequest =
551            serde_json::from_str(&cube_request("Tinted", 5.0)).unwrap();
552        let mut overrides = std::collections::HashMap::new();
553        overrides.insert("Tinted".to_string(), [1.0, 0.0, 0.0]);
554        let mut scene = RenderScene::new();
555        update_scene_from_history(&mut scene, &request, Some(&overrides)).unwrap();
556        assert_eq!(scene.solid("Tinted").unwrap().color_override, Some([1.0, 0.0, 0.0]));
557    }
558
559    #[test]
560    fn scene_insert_replace_and_remove() {
561        let (mut scene, _) = scene_from_history_json(&cube_request("A", 4.0)).unwrap();
562        let (other, _) = scene_from_history_json(&cube_request("B", 2.0)).unwrap();
563        for solid in other.solids() {
564            scene.insert_solid(solid.clone());
565        }
566        assert_eq!(scene.solids().len(), 2);
567        assert!(scene.remove_solid("A"));
568        assert!(!scene.remove_solid("A"));
569        assert_eq!(scene.solids().len(), 1);
570        assert!(scene.solid("B").is_some());
571    }
572
573    #[test]
574    fn datum_history_surfaces_three_named_frames() {
575        // A single DATUM feature resolves three base-plane frames; the build report
576        // surfaces them so the engine can display them as datum planes.
577        let request = serde_json::json!({
578            "expressions": "",
579            "configurator": {},
580            "features": [{
581                "type": "D",
582                "inputParams": { "id": "Datum" },
583                "persistentData": {}
584            }]
585        })
586        .to_string();
587        let (_scene, report) = scene_from_history_json(&request).unwrap();
588        let names: Vec<&str> = report.frames.iter().map(|(n, _)| n.as_str()).collect();
589        assert!(names.contains(&"Datum:XY"), "{names:?}");
590        assert!(names.contains(&"Datum:XZ"), "{names:?}");
591        assert!(names.contains(&"Datum:YZ"), "{names:?}");
592    }
593
594    /// A `RunOutput` — the whole delta (tessellated `SolidDisplay` snapshot + the
595    /// report's frames/profiles/provenance) — survives a serde JSON round trip.
596    /// This is the wasm WorkerRunner (M3) message payload: the worker serializes
597    /// the run result and the main thread reconstructs it byte-for-byte.
598    #[test]
599    fn run_output_round_trips_through_serde() {
600        // A cube (→ a tessellated SolidDisplay) + a DATUM (→ frames) + a committed
601        // rectangle SKETCH (→ a profile) exercise every side-channel of the report.
602        let request: HistoryRequest = serde_json::from_str(
603            r#"{
604                "expressions": "", "configurator": {},
605                "features": [
606                    { "type": "P.CU", "inputParams": { "id": "Box", "sizeX": 10, "sizeY": 10, "sizeZ": 10,
607                        "transform": { "position": [0,0,0], "rotationEuler": [0,0,0], "scale": [1,1,1] },
608                        "boolean": { "targets": [], "operation": "NONE" } }, "persistentData": {} },
609                    { "type": "D", "inputParams": { "id": "Datum" }, "persistentData": {} },
610                    { "type": "S", "inputParams": { "id": "Sk" }, "persistentData": {
611                        "basis": { "origin": [0,0,0], "x": [1,0,0], "y": [0,1,0], "z": [0,0,1] },
612                        "sketch": { "points": [
613                            {"id":0,"x":0,"y":0},{"id":1,"x":10,"y":0},{"id":2,"x":10,"y":6},{"id":3,"x":0,"y":6}],
614                          "geometries": [
615                            {"id":10,"type":"line","points":[0,1]},{"id":11,"type":"line","points":[1,2]},
616                            {"id":12,"type":"line","points":[2,3]},{"id":13,"type":"line","points":[3,0]}],
617                          "constraints": [] } } }
618                ]
619            }"#,
620        )
621        .unwrap();
622
623        let output = SceneRunner::new().run(&request, None);
624        // Sanity: the run produced the box display + the datum frames + the profile.
625        assert_eq!(output.snapshot.len(), 1, "one solid (the box)");
626        assert!(output.snapshot[0].2.as_ref().unwrap().mesh.positions.len() > 0);
627        assert!(output.report.frames.iter().any(|(n, _)| n == "Datum:XY"));
628        assert!(output.report.profiles.iter().any(|(n, _)| n == "Sk"));
629        assert!(output.provenance.iter().any(|(n, id)| n == "Box" && id == "Box"));
630
631        let json = serde_json::to_string(&output).expect("RunOutput serializes");
632        let back: RunOutput = serde_json::from_str(&json).expect("RunOutput deserializes");
633
634        assert_eq!(back.snapshot.len(), output.snapshot.len());
635        assert_eq!(back.snapshot[0].0, "Box");
636        assert_eq!(
637            back.snapshot[0].2.as_ref().unwrap().mesh.positions.len(),
638            output.snapshot[0].2.as_ref().unwrap().mesh.positions.len()
639        );
640        assert!(back.report.frames.iter().any(|(n, _)| n == "Datum:XY"));
641        let (name, profile) = back.report.profiles.iter().find(|(n, _)| n == "Sk").unwrap();
642        assert_eq!(name, "Sk");
643        assert_eq!(profile.regions.len(), 1, "the rectangle profile survived");
644        assert_eq!(back.provenance, output.provenance);
645    }
646}