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}
73
74/// Execute a serialized `HistoryRequest` (the `execute_history_json` request
75/// shape — a saved part file parses as one) and build the display scene from
76/// the final resident solids.
77pub fn scene_from_history_json(
78    request_json: &str,
79) -> Result<(RenderScene, SceneBuildReport), String> {
80    let request: HistoryRequest = serde_json::from_str(request_json)
81        .map_err(|error| format!("history request parse: {error}"))?;
82    scene_from_history(&request)
83}
84
85/// Typed-request variant of [`scene_from_history_json`].
86pub fn scene_from_history(
87    request: &HistoryRequest,
88) -> Result<(RenderScene, SceneBuildReport), String> {
89    let mut scene = RenderScene::new();
90    let report = update_scene_from_history(&mut scene, request)?;
91    Ok((scene, report))
92}
93
94/// The fold of a history run into an ordered scene layout: each entry is the
95/// solid's final name, its resident handle, and whether the feature that
96/// produced it REPLAYED from the incremental cache (R10 — a reused solid is the
97/// same resident geometry, so its display can be kept verbatim and its GPU
98/// buffers reused).
99struct SceneLayout {
100    order: Vec<String>,
101    handles: std::collections::HashMap<String, u32>,
102    reused: std::collections::HashSet<String>,
103    /// `name -> creating-feature id` of the FINAL resident SOLIDS (last writer
104    /// wins; a `removed` name drops its entry) — the eager provenance the runner
105    /// ships so the main thread never has to re-run the history to answer
106    /// "what feature produced this SOLID?". Matches the resident semantics of the
107    /// old `resident_handles_and_creators`.
108    creators: std::collections::HashMap<String, String>,
109    /// `face/edge NAME -> ORIGINATING feature id` — the FIRST feature (timeline
110    /// order) to emit each face/edge name, i.e. the entity's TRUE origin (the
111    /// feature that gave it its name). Unlike `creators` this is FIRST-writer-wins
112    /// and is NEVER pruned on `removed`: a boolean removes its target solid and
113    /// re-adds the same solid name carrying mostly the same face names, so pruning
114    /// then re-adding would reset those origins to the boolean feature — the same
115    /// last-writer bug one level down. Accepted edge case: a fully-deleted solid
116    /// whose name later recurs on unrelated geometry keeps its old origin — but
117    /// under this app's DETERMINISTIC naming a recurring name is the same
118    /// conceptual entity, and the whole map is rebuilt every run, so it can never
119    /// point at a feature that was deleted from the history.
120    entity_origin: std::collections::HashMap<String, String>,
121}
122
123fn fold_history(result: &brep_kernel::HistoryResult, report: &mut SceneBuildReport) -> SceneLayout {
124    // Removals first (a boolean result reuses a removed target's name), then
125    // additions, insertion-ordered — mirrors SceneMap::apply.
126    let mut order: Vec<String> = Vec::new();
127    let mut handles: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
128    let mut reused: std::collections::HashSet<String> = std::collections::HashSet::new();
129    let mut creators: std::collections::HashMap<String, String> = std::collections::HashMap::new();
130    let mut entity_origin: std::collections::HashMap<String, String> =
131        std::collections::HashMap::new();
132    for feature in &result.results {
133        for removed in &feature.removed {
134            if handles.remove(removed).is_some() {
135                order.retain(|name| name != removed);
136            }
137            reused.remove(removed);
138            creators.remove(removed);
139            // NOTE: `entity_origin` is deliberately NOT pruned here — see its doc on
140            // `SceneLayout`. First-writer-with-no-pruning is the whole point.
141        }
142        for added in &feature.added {
143            if handles.insert(added.name.clone(), added.handle).is_none() {
144                order.push(added.name.clone());
145            }
146            creators.insert(added.name.clone(), feature.id.clone());
147            // The FIRST feature to emit a given face/edge name IS its origin (this
148            // loop is in timeline order). `entry(..).or_insert_with` keeps that
149            // first writer — using `insert` here would be last-writer and silently
150            // reproduce the exact bug this map exists to fix.
151            for (_, name) in &added.face_names {
152                entity_origin
153                    .entry(name.clone())
154                    .or_insert_with(|| feature.id.clone());
155            }
156            for (_, name) in &added.edge_names {
157                entity_origin
158                    .entry(name.clone())
159                    .or_insert_with(|| feature.id.clone());
160            }
161            // A solid displays as reused only when its whole producing feature
162            // replayed unchanged; a re-run feature re-tessellates.
163            if feature.reused {
164                reused.insert(added.name.clone());
165            } else {
166                reused.remove(&added.name);
167            }
168        }
169        if let Some(error) = &feature.error {
170            report.feature_errors.push(format!("{}: {error}", feature.id));
171        }
172        for name in &feature.unresolved {
173            report.unresolved.push(format!("{}: {name}", feature.id));
174        }
175        // The feature's output solid name(s) — the history tree's Outputs node.
176        report.feature_outputs.push((
177            feature.id.clone(),
178            feature.added.iter().map(|a| a.name.clone()).collect(),
179        ));
180        // The named plane frames this feature resolved (DATUM three / PLANE one /
181        // SKETCH its own). Carried straight through so the engine can display the
182        // construction datum/plane frames (it filters to the D/P producers).
183        for (name, frame) in &feature.frames {
184            report.frames.push((name.clone(), *frame));
185        }
186        // The solved sketch profile (SKETCH features publish one under `{id}`) —
187        // carried straight through so the engine can synthesize its sheet solid.
188        for (name, profile) in &feature.profiles {
189            report.profiles.push((name.clone(), profile.clone()));
190        }
191        // The named axis lines this feature published (a SKETCH emits one per line
192        // geometry) — carried through so the engine can resolve a revolve `axis`
193        // reference to a world line for the angle gizmo.
194        for (name, axis) in &feature.axes {
195            report.axes.push((name.clone(), *axis));
196        }
197        // The named path chains this feature published (a SKETCH emits its whole
198        // chain under `{id}` and every model segment under `{id}:G{gid}`) — carried
199        // through so the engine can draw the segments no closed profile covers.
200        for (name, curves) in &feature.paths {
201            report.paths.push((name.clone(), curves.clone()));
202        }
203        // The named world points this feature published (a SKETCH emits every
204        // solved point under `{id}:P{pid}`) — carried through so the engine can
205        // draw a sketch's standalone points, which no segment covers.
206        for (name, point) in &feature.points {
207            report.points.push((name.clone(), *point));
208        }
209    }
210    // Per-feature timing rides the kernel result straight through.
211    report.feature_timings = result.timings.clone();
212    SceneLayout { order, handles, reused, creators, entity_origin }
213}
214
215/// A stateful, SCENE-FREE history run that emits a DELTA snapshot instead of
216/// mutating a scene — the M1 seam of the off-thread history runner. It remembers,
217/// in [`Self::last_sent`], the resident handle it last EMITTED per solid name, so
218/// a rerun can tell the applier which displays are UNCHANGED (skip re-tessellating
219/// + keep their GPU buffers) vs. which are new/rebound (freshly tessellated).
220///
221/// Being scene-free is the point: a later milestone runs this on a background
222/// thread / worker with no access to the render scene, then ships the
223/// [`RunOutput`] delta back to the main thread to apply. In M1 the run is
224/// immediately applied on the same thread (see `EngineState::apply_run_output`),
225/// so behavior is byte-identical to the pre-seam in-place reconcile.
226pub struct SceneRunner {
227    /// `name -> handle` of what was last EMITTED (the delta baseline). Handles are
228    /// monotonic and never recycled (the resident registry's counter only
229    /// increments, and `clear_history_cache` frees solids without resetting it),
230    /// so "handle unchanged since last emit" is EXACTLY the old
231    /// `reused && source_handle matches` reuse condition — a re-executed feature
232    /// always registers a NEW handle, so a matching handle proves a cache replay.
233    last_sent: HashMap<String, u32>,
234    /// The display LOD factor the current baseline was tessellated at. When a run
235    /// arrives at a DIFFERENT lod (the user moved the "LOD factor" slider) every
236    /// resident mesh must be rebuilt at the new chord tolerance even though its
237    /// handle is unchanged — so a lod change drops `last_sent` to defeat the
238    /// handle-unchanged reuse fast path. Init `1.0` (the "Normal" default) so a
239    /// first run at the default lod does NOT spuriously invalidate an empty
240    /// baseline's peers.
241    last_lod: f64,
242    /// The parts-library revision this runner's OWN kernel store was last
243    /// installed at (`None` = nothing installed, or dropped). The library no
244    /// longer rides on every history request (that per-run serialize is what
245    /// froze the browser UI on a large assembly) — it arrives on its own
246    /// `Command::SetPartsLibrary` and this records what landed, so a run
247    /// stamped for a different revision can be refused rather than executed
248    /// against the wrong parts. Cleared by [`Self::reset`], which pairs with
249    /// the `clear_history_cache` that empties the store.
250    pub(crate) parts_library_revision: Option<u64>,
251    /// The part NAMES the last install carried. A run whose ACOMP references a
252    /// name in here that the store can no longer resolve was GC'd out from
253    /// under us and is RECOVERABLE (ask for the library again); a name that was
254    /// never installed is simply not in the document's library, and that run
255    /// must go ahead so the ACOMP feature reports it — refusing forever would
256    /// wedge the model on a dangling reference. Names only, so this costs
257    /// nothing next to the payload it describes.
258    pub(crate) parts_library_names: std::collections::BTreeSet<String>,
259}
260
261/// The delta a [`SceneRunner::run`] produces: the displayed KERNEL solids IN
262/// ORDER plus the run's [`SceneBuildReport`]. The applier walks `snapshot` in
263/// order, reusing or replacing each entry (see the field docs).
264#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
265pub struct RunOutput {
266    /// The displayed KERNEL solids IN ORDER. `display: Some` = freshly tessellated
267    /// (new, or the handle changed since last emit); `None` = UNCHANGED — the
268    /// applier keeps its existing [`SolidDisplay`] for this name, whose
269    /// `source_handle` equals `handle`.
270    pub snapshot: Vec<(String /* name */, u32 /* handle */, Option<SolidDisplay>)>,
271    pub report: SceneBuildReport,
272    /// Eager PROVENANCE: `name -> creating-feature id` for the run's FINAL resident
273    /// SOLIDS (same last-writer-wins fold as the display snapshot). Shipped WITH the
274    /// run so the main thread can answer SOLID provenance without a cold
275    /// `execute_history` — critical once the run lives on a background thread and the
276    /// main-side registry is cold.
277    pub provenance: Vec<(String, String)>,
278    /// Eager ENTITY ORIGIN: `face/edge NAME -> ORIGINATING feature id` (FIRST writer
279    /// in timeline order, never pruned — see `SceneLayout::entity_origin`). Shipped
280    /// beside `provenance` so `creating_feature` can answer "which feature gave this
281    /// face/edge its name?" (the "Edit owning feature" context action + the Info
282    /// tab's `creatingFeature`) with no cold re-run. Crosses the background-worker
283    /// seam, so the serde round-trip test asserts it survives.
284    pub entity_origin: Vec<(String, String)>,
285    /// Assembly-solver POSE write-backs from this run's constraint tail:
286    /// `(component feature id, {translate, rotateEulerDeg})` — read RUNNER-SIDE
287    /// (the kernel assembly session is thread-local to the thread/worker that ran
288    /// `execute_history`, so only the runner can see it) and shipped here so the
289    /// engine folds them into the owning ACOMP features' `inputParams` (the
290    /// pose-authority contract, build-spec §6 step 4). Empty on a no-motion
291    /// solve — a satisfied assembly must not churn feature fingerprints.
292    /// `#[serde(default)]` keeps pre-assembly serialized replies deserializing.
293    #[serde(default)]
294    pub assembly_poses: Vec<(String, serde_json::Value)>,
295    /// `isFixed` write-backs riding the same fold (the Fixed-constraint lane
296    /// grounds a component); shape mirrors [`Self::assembly_poses`].
297    #[serde(default)]
298    pub assembly_fixed: Vec<(String, bool)>,
299    /// Names of solids the solve re-posed IN PLACE this run (the display seam:
300    /// their producing feature may have replayed `reused`, yet their geometry
301    /// moved). The runner already defeated the handle-unchanged reuse fast path
302    /// for these (their snapshot entries arrive `Some`, freshly tessellated);
303    /// shipped for observability/tests. Empty on zero-mate/no-motion runs.
304    #[serde(default)]
305    pub moved_solids: Vec<String>,
306    /// IMPORTED COLOURS: `entity name -> "#RRGGBB"` for every solid/face this run
307    /// left a `color` scene-metadata record on (STEP presentation entities, read
308    /// by `brep_kernel::io/appearance.rs` and stamped by IMPORT3D).
309    ///
310    /// Read RUNNER-SIDE for the same reason as `assembly_poses`: the kernel's
311    /// scene-metadata store is thread-local to whoever ran `execute_history`, so
312    /// a main-thread read under a background runner sees nothing. The applier
313    /// folds these into the engine's own [`crate::metadata::MetadataStore`]
314    /// WITHOUT overwriting, so the Info window shows an imported colour and a
315    /// user's edit of it still wins.
316    ///
317    /// Filtered to the names this run actually produced, so a colour left in the
318    /// (never-cleared) kernel store by a previous document cannot bleed into
319    /// this one.
320    #[serde(default)]
321    pub imported_colors: Vec<(String, String)>,
322}
323
324impl SceneRunner {
325    pub fn new() -> Self {
326        Self {
327            last_sent: HashMap::new(),
328            last_lod: 1.0,
329            parts_library_revision: None,
330            parts_library_names: std::collections::BTreeSet::new(),
331        }
332    }
333
334    /// Reset the delta baseline (call on a wholesale document switch so the next
335    /// run is a full rebuild — no stale reuse across unrelated models).
336    pub fn reset(&mut self) {
337        self.last_sent.clear();
338        // A reset always accompanies the `clear_history_cache` that empties
339        // this side's parts library, so forget what was installed — the next
340        // run's stamp will not match and the library is re-sent.
341        self.parts_library_revision = None;
342        self.parts_library_names.clear();
343    }
344
345    /// Execute `request` and fold it into an ordered delta snapshot (does NOT
346    /// touch any scene). For each displayed kernel solid in order: if the handle
347    /// is unchanged since the last emit, emit `(name, handle, None)` (the applier
348    /// keeps its existing display); otherwise (re-)tessellate the resident handle
349    /// and emit `(name, handle, Some(display))`. A display-payload error is
350    /// recorded in `report.display_errors` and the entry is SKIPPED — and NOT
351    /// recorded in the new baseline, so a later successful run re-tessellates it.
352    ///
353    ///
354    /// Colours are NOT applied here. A display arrives colourless and the main
355    /// side paints it from the metadata store
356    /// ([`EngineState::sync_colors_from_metadata`](crate::engine_state::EngineState::sync_colors_from_metadata)),
357    /// which is the only colour authority — a runner may be a background thread
358    /// or worker with no view of that store.
359    pub fn run(&mut self, request: &HistoryRequest) -> RunOutput {
360        self.run_observed(request, &mut |_| true)
361    }
362
363    /// [`Self::run`] with a progress observer: `observe` is called before every
364    /// feature the kernel actually executes (see
365    /// [`brep_kernel::execute_history_observed`]); returning `false` stops the
366    /// run at that boundary, and the partial result is folded like any other.
367    pub fn run_observed(
368        &mut self,
369        request: &HistoryRequest,
370        observe: &mut dyn FnMut(brep_kernel::HistoryProgress<'_>) -> bool,
371    ) -> RunOutput {
372        let result = brep_kernel::execute_history_observed(request, observe);
373        let mut report = SceneBuildReport::default();
374        let layout = fold_history(&result, &mut report);
375
376        // --- assembly pose-authority read (RUNNER-SIDE, build-spec §6/§13) ----
377        // The kernel assembly session (constraint solve tail) is THREAD-LOCAL to
378        // whoever ran `execute_history` — i.e. this thread/worker — so the pose
379        // and isFixed write-backs must be read HERE and shipped in the RunOutput;
380        // a main-thread read under a background runner would see a cold session.
381        let pose_updates: serde_json::Value =
382            serde_json::from_str(&brep_kernel::assembly_pose_updates_json())
383                .unwrap_or(serde_json::Value::Null);
384        let assembly_poses: Vec<(String, serde_json::Value)> = pose_updates["poses"]
385            .as_object()
386            .map(|map| map.iter().map(|(id, pose)| (id.clone(), pose.clone())).collect())
387            .unwrap_or_default();
388        let assembly_fixed: Vec<(String, bool)> = pose_updates["isFixed"]
389            .as_object()
390            .map(|map| {
391                map.iter()
392                    .filter_map(|(id, flag)| flag.as_bool().map(|b| (id.clone(), b)))
393                    .collect()
394            })
395            .unwrap_or_default();
396        // The display seam: solids the solve re-posed IN PLACE keep their resident
397        // handle, so the handle-unchanged reuse fast path below would wrongly skip
398        // re-tessellating them even though their geometry moved. Drop them from
399        // the baseline so they re-emit fresh displays. The `movedSolids` key is
400        // ABSENT on zero-mate reports — tolerated (empty).
401        let dof: serde_json::Value = serde_json::from_str(&brep_kernel::assembly_dof_json())
402            .unwrap_or(serde_json::Value::Null);
403        let moved_solids: Vec<String> = dof["movedSolids"]
404            .as_array()
405            .map(|items| {
406                items
407                    .iter()
408                    .filter_map(|item| item["name"].as_str().map(String::from))
409                    .collect()
410            })
411            .unwrap_or_default();
412        for name in &moved_solids {
413            self.last_sent.remove(name);
414        }
415
416        // Display LOD: a finite, positive factor (garbage from a hand-edited saved
417        // file → the "Normal" 1.0, since chord = extent·1.5e-3·lod and a 0/NaN lod
418        // would zero the tolerance → runaway refinement). A change since the last
419        // run means every resident mesh must re-tessellate at the new chord even
420        // though its handle is unchanged, so drop the reuse baseline.
421        let lod = if request.display_lod.is_finite() && request.display_lod > 0.0 {
422            request.display_lod
423        } else {
424            1.0
425        };
426        if lod != self.last_lod {
427            self.last_sent.clear();
428            self.last_lod = lod;
429        }
430
431        let mut snapshot: Vec<(String, u32, Option<SolidDisplay>)> =
432            Vec::with_capacity(layout.order.len());
433        let mut next_sent: HashMap<String, u32> = HashMap::with_capacity(layout.order.len());
434
435        for name in &layout.order {
436            let handle = layout.handles[name];
437            if self.last_sent.get(name) == Some(&handle) {
438                // Handle unchanged since last emit ⇒ same resident geometry ⇒ the
439                // applier keeps its existing display. This is EXACTLY the old
440                // `reused && source_handle == handle` fast path (monotonic handles):
441                // the canary below verifies the implication holds.
442                debug_assert!(
443                    layout.reused.contains(name),
444                    "handle unchanged must imply reused (monotonic handles): {name}"
445                );
446                snapshot.push((name.clone(), handle, None));
447                next_sent.insert(name.clone(), handle);
448                continue;
449            }
450            match display_payload_handle_native(handle, lod) {
451                Ok(payload) => {
452                    let mut solid = solid_display_from_payload(name, payload);
453                    solid.source_handle = handle;
454                    // Runner thread → the SheetTree thread-local is warm here; stamp
455                    // the sheet-metal marker so the UI thread reads it off the scene
456                    // (never calling the thread-local from a cold UI thread).
457                    solid.is_sheet_metal = brep_kernel::is_sheet_metal_handle(handle);
458                    snapshot.push((name.clone(), handle, Some(solid)));
459                    next_sent.insert(name.clone(), handle);
460                }
461                // A tessellation failure skips the solid (non-fatal) and does NOT
462                // poison the baseline — dropping it lets a later successful run
463                // re-tessellate from scratch.
464                Err(error) => report.display_errors.push(format!("{name}: {error}")),
465            }
466        }
467
468        self.last_sent = next_sent;
469        // Eager provenance for the run's final resident solids (the applier stores
470        // this map main-side so per-frame provenance queries never re-run history).
471        let provenance: Vec<(String, String)> = layout
472            .creators
473            .iter()
474            .map(|(name, id)| (name.clone(), id.clone()))
475            .collect();
476        // Eager entity origin (face/edge NAME -> originating feature id) rides
477        // alongside, so face/edge provenance survives the off-thread seam too.
478        let entity_origin: Vec<(String, String)> = layout
479            .entity_origin
480            .iter()
481            .map(|(name, id)| (name.clone(), id.clone()))
482            .collect();
483        // Imported colours, read HERE for the thread-local reason above and
484        // filtered to this run's own entities: the solids it displays plus the
485        // faces/edges it named. The kernel store is never cleared between
486        // documents, so an unfiltered ship could hand the applier a colour that
487        // belongs to a model the user closed.
488        let imported_colors: Vec<(String, String)> = serde_json::from_str::<serde_json::Value>(
489            &brep_kernel::scene_metadata_colors_json(),
490        )
491        .ok()
492        .and_then(|value| value.as_object().cloned())
493        .map(|map| {
494            map.into_iter()
495                .filter(|(name, _)| {
496                    layout.handles.contains_key(name) || layout.entity_origin.contains_key(name)
497                })
498                .filter_map(|(name, hex)| hex.as_str().map(|hex| (name, hex.to_string())))
499                .collect()
500        })
501        .unwrap_or_default();
502
503        RunOutput {
504            snapshot,
505            report,
506            provenance,
507            entity_origin,
508            assembly_poses,
509            assembly_fixed,
510            moved_solids,
511            imported_colors,
512        }
513    }
514
515    /// The resident handle last EMITTED for `name` (the delta baseline), or `None`
516    /// if the runner has not emitted a solid of that name. The measurement-query
517    /// path resolves an object's owning-solid handle through this so the query runs
518    /// against the SAME resident geometry the last run displayed (on the runner's
519    /// own thread, whose registry is warm from that run).
520    pub fn handle_of(&self, name: &str) -> Option<u32> {
521        self.last_sent.get(name).copied()
522    }
523}
524
525impl Default for SceneRunner {
526    fn default() -> Self {
527        Self::new()
528    }
529}
530
531/// Execute the history and reconcile `scene` in place (R10 incremental update):
532/// reused solids already present keep their [`SolidDisplay`] verbatim (stable
533/// `revision` ⇒ the renderer reuses their GPU buffers); everything else is
534/// (re-)tessellated from the resident handle; departed solids are dropped.
535///
536/// Implemented on the M1 seam: a throwaway [`SceneRunner`] primed from the
537/// CURRENT scene (`name → source_handle` of what is displayed) reproduces the
538/// pre-seam reuse in a single synchronous call, and its [`RunOutput`] delta is
539/// applied back to `scene` by MOVING existing displays out (via
540/// [`RenderScene::drain`]) so a reused solid's mesh is never cloned.
541pub fn update_scene_from_history(
542    scene: &mut RenderScene,
543    request: &HistoryRequest,
544) -> Result<SceneBuildReport, String> {
545    let mut runner = SceneRunner::new();
546    runner.last_sent = scene
547        .solids()
548        .iter()
549        .map(|solid| (solid.name.clone(), solid.source_handle))
550        .collect();
551    let output = runner.run(request);
552
553    // Move the current displays out, then reinsert in snapshot ORDER: a fresh
554    // entry replaces, an UNCHANGED entry reuses the moved-out display (whose
555    // `source_handle` equals the run's handle — guaranteed by the reuse
556    // invariant). Leftovers (departed names) are dropped.
557    let mut kept: HashMap<String, SolidDisplay> = scene
558        .drain()
559        .into_iter()
560        .map(|solid| (solid.name.clone(), solid))
561        .collect();
562    for (name, _handle, maybe) in output.snapshot {
563        match maybe {
564            Some(display) => scene.insert_solid(display),
565            None => scene.insert_solid(
566                kept.remove(&name).expect("keep target present"),
567            ),
568        }
569    }
570    Ok(output.report)
571}
572
573/// Execute `request` and return the FINAL resident solids as `(name, handle)` in
574/// display order — the export lane (STEP/STL) needs the current solids' resident
575/// handles, which the scene build does not itself retain. Run right after a scene
576/// build (warm incremental cache) this replays the cached features, so the
577/// handles it returns are the very ones the displayed scene was built from; run
578/// cold it re-executes and registers fresh (still-valid) handles. Either way the
579/// handles are live in the thread-local registry when this returns, ready to
580/// hand to `brep_kernel::export_step_handles`.
581pub fn resident_solid_handles(request: &HistoryRequest) -> Vec<(String, u32)> {
582    let result = execute_history(request);
583    let mut report = SceneBuildReport::default();
584    let layout = fold_history(&result, &mut report);
585    layout
586        .order
587        .iter()
588        .map(|name| (name.clone(), layout.handles[name]))
589        .collect()
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595
596    fn cube_request(name: &str, size: f64) -> String {
597        serde_json::json!({
598            "expressions": "",
599            "configurator": {},
600            "features": [{
601                "type": "P.CU",
602                "inputParams": {
603                    "id": name,
604                    "sizeX": size, "sizeY": size, "sizeZ": size,
605                    "transform": {
606                        "position": [0.0, 0.0, 0.0],
607                        "rotationEuler": [0.0, 0.0, 0.0],
608                        "scale": [1.0, 1.0, 1.0]
609                    },
610                    "boolean": { "targets": [], "operation": "NONE" }
611                },
612                "persistentData": {}
613            }]
614        })
615        .to_string()
616    }
617
618    // A DOTTED feature id (the new `{shortName}{N}` scheme yields ids like `P.CU1`)
619    // must be safe as a downstream reference: the `.` is inert everywhere (scene-map
620    // resolution is exact HashMap lookup — only `:` and `|` are name delimiters).
621    // Prove it at RUNTIME: a boolean SUBTRACT that targets a dotted-id solid resolves
622    // (nothing lands in `report.unresolved`) and builds a scene.
623    #[test]
624    fn dotted_feature_id_resolves_as_a_boolean_reference() {
625        let request = serde_json::json!({
626            "expressions": "",
627            "configurator": {},
628            "features": [
629                {
630                    "type": "P.CU",
631                    "inputParams": {
632                        "id": "P.CU1",
633                        "sizeX": 10.0, "sizeY": 10.0, "sizeZ": 10.0,
634                        "transform": { "position": [0.0, 0.0, 0.0], "rotationEuler": [0.0, 0.0, 0.0], "scale": [1.0, 1.0, 1.0] },
635                        "boolean": { "targets": [], "operation": "NONE" }
636                    },
637                    "persistentData": {}
638                },
639                {
640                    "type": "P.CU",
641                    "inputParams": {
642                        "id": "P.CU2",
643                        "sizeX": 10.0, "sizeY": 10.0, "sizeZ": 10.0,
644                        "transform": { "position": [5.0, 0.0, 0.0], "rotationEuler": [0.0, 0.0, 0.0], "scale": [1.0, 1.0, 1.0] },
645                        // SUBTRACT referencing the DOTTED id of the first cube.
646                        "boolean": { "targets": ["P.CU1"], "operation": "SUBTRACT" }
647                    },
648                    "persistentData": {}
649                }
650            ]
651        })
652        .to_string();
653        let (scene, report) = scene_from_history_json(&request).unwrap();
654        assert!(report.feature_errors.is_empty(), "{:?}", report.feature_errors);
655        // The dotted reference `P.CU1` resolved — if `.` broke name matching it would
656        // appear here instead.
657        assert!(report.unresolved.is_empty(), "dotted ref unresolved: {:?}", report.unresolved);
658        // The subtract folded the two cubes into one resident solid.
659        assert_eq!(scene.solids().len(), 1);
660    }
661
662    #[test]
663    fn cube_history_populates_scene() {
664        let (scene, report) = scene_from_history_json(&cube_request("P.CU1", 10.0)).unwrap();
665        assert!(report.feature_errors.is_empty(), "{:?}", report.feature_errors);
666        assert!(report.display_errors.is_empty(), "{:?}", report.display_errors);
667        assert_eq!(scene.solids().len(), 1);
668        let solid = scene.solid("P.CU1").expect("solid keyed by kernel name");
669        assert_eq!(solid.faces.len(), 6);
670        // Cube tessellation: 2 triangles per face.
671        assert_eq!(solid.mesh.indices.len(), 6 * 2 * 3);
672        for face in &solid.faces {
673            assert_eq!(face.tri_count, 2, "face {} range", face.name);
674            assert!(!face.name.is_empty());
675        }
676        // 12 boundary edges, each a straight 2-point polyline, all named.
677        assert_eq!(solid.edges.len(), 12);
678        for edge in &solid.edges {
679            assert!(edge.polyline.len() >= 2);
680            assert!(!edge.name.is_empty());
681        }
682        assert_eq!(solid.vertices.len(), 8);
683        let bbox = scene.bbox();
684        assert!((bbox.size()[0] - 10.0).abs() < 1e-6);
685    }
686
687    fn sphere_request(name: &str, radius: f64, lod: f64) -> brep_kernel::HistoryRequest {
688        let mut request: brep_kernel::HistoryRequest = serde_json::from_value(serde_json::json!({
689            "expressions": "",
690            "configurator": {},
691            "features": [{
692                "type": "P.S",
693                "inputParams": {
694                    "id": name,
695                    "radius": radius,
696                    "transform": {
697                        "position": [0.0, 0.0, 0.0],
698                        "rotationEuler": [0.0, 0.0, 0.0],
699                        "scale": [1.0, 1.0, 1.0]
700                    },
701                    "boolean": { "targets": [], "operation": "NONE" }
702                },
703                "persistentData": {}
704            }]
705        }))
706        .unwrap();
707        request.display_lod = lod;
708        request
709    }
710
711    // A LOD-factor change must re-tessellate every resident mesh at the new chord
712    // tolerance EVEN THOUGH the solid's handle is unchanged (same geometry, replayed
713    // from the incremental cache) — otherwise the "LOD factor" slider does nothing.
714    #[test]
715    fn lod_change_retessellates_reused_solid_coarser() {
716        let mut runner = SceneRunner::new();
717
718        // Fine mesh at lod 0.5.
719        let fine = runner.run(&sphere_request("LodBall", 10.0, 0.5));
720        let (_, fine_handle, fine_display) = &fine.snapshot[0];
721        let fine_tris = fine_display.as_ref().expect("first run tessellates").mesh.indices.len();
722
723        // SAME sphere, coarser lod 4.0: the incremental cache replays the sphere so
724        // the handle is unchanged (proven below) — the ONLY reason to re-emit is the
725        // lod change, and the coarser chord must drop the triangle count.
726        let coarse = runner.run(&sphere_request("LodBall", 10.0, 4.0));
727        let (_, coarse_handle, coarse_display) = &coarse.snapshot[0];
728        assert_eq!(
729            coarse_handle, fine_handle,
730            "same sphere must replay to the same handle (reuse path) — else the test proves nothing"
731        );
732        let coarse_tris = coarse_display
733            .as_ref()
734            .expect("a lod change re-emits Some(display) despite the unchanged handle")
735            .mesh
736            .indices
737            .len();
738        assert!(
739            coarse_tris < fine_tris,
740            "coarser lod must shrink the mesh: fine(lod 0.5)={fine_tris} coarse(lod 4.0)={coarse_tris}"
741        );
742
743        // Re-running at the SAME lod reuses the baseline (None) — no spurious full
744        // re-tessellation on every run once the lod is stable.
745        let again = runner.run(&sphere_request("LodBall", 10.0, 4.0));
746        assert!(
747            again.snapshot[0].2.is_none(),
748            "an unchanged lod must reuse the display baseline (None), not re-tessellate"
749        );
750    }
751
752    #[test]
753    fn reused_history_keeps_stable_revision() {
754        let request: brep_kernel::HistoryRequest =
755            serde_json::from_str(&cube_request("Keep", 8.0)).unwrap();
756        let mut scene = RenderScene::new();
757        update_scene_from_history(&mut scene, &request).unwrap();
758        let first_rev = scene.solid("Keep").unwrap().revision;
759
760        // A second identical run replays from the incremental cache; the reused
761        // solid must keep its revision (so the renderer reuses its GPU buffers).
762        update_scene_from_history(&mut scene, &request).unwrap();
763        assert_eq!(scene.solid("Keep").unwrap().revision, first_rev);
764    }
765
766    #[test]
767    fn scene_insert_replace_and_remove() {
768        let (mut scene, _) = scene_from_history_json(&cube_request("A", 4.0)).unwrap();
769        let (other, _) = scene_from_history_json(&cube_request("B", 2.0)).unwrap();
770        for solid in other.solids() {
771            scene.insert_solid(solid.clone());
772        }
773        assert_eq!(scene.solids().len(), 2);
774        assert!(scene.remove_solid("A"));
775        assert!(!scene.remove_solid("A"));
776        assert_eq!(scene.solids().len(), 1);
777        assert!(scene.solid("B").is_some());
778    }
779
780    #[test]
781    fn datum_history_surfaces_three_named_frames() {
782        // A single DATUM feature resolves three base-plane frames; the build report
783        // surfaces them so the engine can display them as datum planes.
784        let request = serde_json::json!({
785            "expressions": "",
786            "configurator": {},
787            "features": [{
788                "type": "D",
789                "inputParams": { "id": "Datum" },
790                "persistentData": {}
791            }]
792        })
793        .to_string();
794        let (_scene, report) = scene_from_history_json(&request).unwrap();
795        let names: Vec<&str> = report.frames.iter().map(|(n, _)| n.as_str()).collect();
796        assert!(names.contains(&"Datum:XY"), "{names:?}");
797        assert!(names.contains(&"Datum:XZ"), "{names:?}");
798        assert!(names.contains(&"Datum:YZ"), "{names:?}");
799    }
800
801    /// A Box → Pin → Cut(SUBTRACT) history: the SOLID provenance is LAST-writer
802    /// (`Box → Cut`) while `entity_origin` is FIRST-writer, so an ORIGINAL box face
803    /// still points at `Box`. This pins the divergence the "Edit owning feature" fix
804    /// relies on: the two maps must disagree for the same final solid.
805    #[test]
806    fn entity_origin_is_first_writer_while_provenance_is_last_writer() {
807        // The app's boot seed: a 20 mm cube `Box`, a cylinder `Pin`, and
808        // `Cut` = SUBTRACT(Box, [Pin]). The SUBTRACT reuses the target's name, so the
809        // final solid is `Box`, produced by `Cut`.
810        let request: HistoryRequest = serde_json::from_str(
811            r#"{
812                "expressions": "", "configurator": {},
813                "features": [
814                    { "type": "P.CU", "inputParams": { "id": "Box", "sizeX": 20, "sizeY": 20, "sizeZ": 20,
815                        "transform": { "position": [0,0,0], "rotationEuler": [0,0,0], "scale": [1,1,1] },
816                        "boolean": { "targets": [], "operation": "NONE" } }, "persistentData": {} },
817                    { "type": "P.CY", "inputParams": { "id": "Pin", "radius": 6, "height": 30,
818                        "transform": { "position": [10,-5,10], "rotationEuler": [0,0,0], "scale": [1,1,1] },
819                        "boolean": { "targets": [], "operation": "NONE" } }, "persistentData": {} },
820                    { "type": "B", "inputParams": { "id": "Cut", "targetSolid": "Box",
821                        "boolean": { "operation": "SUBTRACT", "targets": ["Pin"] } }, "persistentData": {} }
822                ]
823            }"#,
824        )
825        .unwrap();
826
827        let output = SceneRunner::new().run(&request);
828
829        // One final solid, `Box`, whose SOLID producer is the LAST writer, `Cut`.
830        let provenance: std::collections::HashMap<_, _> = output.provenance.iter().cloned().collect();
831        assert_eq!(provenance.get("Box"), Some(&"Cut".to_string()));
832
833        // Its ORIGINAL faces still ORIGINATE from `Box` (first writer, not `Cut`):
834        // cross-check against the faces actually resident on the final `Box` display.
835        let entity_origin: std::collections::HashMap<_, _> =
836            output.entity_origin.iter().cloned().collect();
837        let box_display = output
838            .snapshot
839            .iter()
840            .find(|(name, _, _)| name == "Box")
841            .and_then(|(_, _, d)| d.as_ref())
842            .expect("Box freshly tessellated on first run");
843        let a_box_origin_face = box_display
844            .faces
845            .iter()
846            .any(|f| entity_origin.get(&f.name) == Some(&"Box".to_string()));
847        assert!(
848            a_box_origin_face,
849            "at least one resident Box face must originate from `Box`, not the `Cut` \
850             boolean that last produced the solid; entity_origin = {entity_origin:?}"
851        );
852    }
853
854    /// The assembly display seam + pose-authority read, end-to-end at the runner:
855    /// a constraint added to an ALREADY-BUILT two-instance assembly replays both
856    /// ACOMP features from the cache (unchanged handles), yet the solve re-poses
857    /// the free instance IN PLACE — the runner must (a) ship the solver's pose
858    /// write-back in `assembly_poses`, (b) list the re-posed member in
859    /// `moved_solids`, and (c) DEFEAT the handle-unchanged reuse fast path for it
860    /// (a `Some`, freshly tessellated snapshot entry at the MOVED location) while
861    /// the fixed instance stays a reuse (`None`).
862    #[test]
863    fn assembly_solve_ships_poses_and_retessellates_moved_solids() {
864        let base_json = crate::engine_state::component_fixtures::two_instance_assembly_json();
865        let base: HistoryRequest = serde_json::from_str(&base_json).unwrap();
866
867        let mut runner = SceneRunner::new();
868        let first = runner.run(&base);
869        assert!(
870            first.assembly_poses.is_empty() && first.moved_solids.is_empty(),
871            "no constraints yet — no write-backs: {:?}",
872            first.assembly_poses
873        );
874        let first_x = |name: &str| {
875            first
876                .snapshot
877                .iter()
878                .find(|(n, _, _)| n == name)
879                .and_then(|(_, _, d)| d.as_ref())
880                .expect("first run tessellates fresh")
881                .bbox
882                .min[0]
883        };
884        assert!((first_x("ACOMP2:Part") - 20.0).abs() < 1e-6, "authored pose");
885
886        // SAME features + a whole-component coincident: ACOMP2 must land on ACOMP1.
887        let mut doc: serde_json::Value = serde_json::from_str(&base_json).unwrap();
888        doc["assembly"] = serde_json::json!({
889            "constraints": [{
890                "type": "coincident",
891                "inputParams": { "id": "COIN1", "elements": ["ACOMP1", "ACOMP2"] },
892                "persistentData": {},
893                "enabled": true,
894                "open": false
895            }],
896            "idCounter": 2
897        });
898        let with_constraint: HistoryRequest = serde_json::from_str(&doc.to_string()).unwrap();
899        let second = runner.run(&with_constraint);
900
901        // (a) the pose write-back for the FREE component only.
902        assert!(
903            second.assembly_poses.iter().any(|(id, pose)| {
904                id == "ACOMP2" && pose.get("translate").is_some() && pose.get("rotateEulerDeg").is_some()
905            }),
906            "ACOMP2 pose shipped: {:?}",
907            second.assembly_poses
908        );
909        assert!(
910            !second.assembly_poses.iter().any(|(id, _)| id == "ACOMP1"),
911            "the fixed component never writes back"
912        );
913
914        // (b) the display seam names the re-posed member.
915        assert!(
916            second.moved_solids.iter().any(|n| n == "ACOMP2:Part"),
917            "moved solids: {:?}",
918            second.moved_solids
919        );
920
921        // (c) the moved solid re-tessellated DESPITE its replayed feature; the
922        // fixed one is a plain reuse.
923        let entry = |name: &str| second.snapshot.iter().find(|(n, _, _)| n == name).unwrap();
924        assert!(
925            entry("ACOMP1:Part").2.is_none(),
926            "fixed instance replays as a reuse"
927        );
928        let moved = entry("ACOMP2:Part")
929            .2
930            .as_ref()
931            .expect("moved solid must re-emit a fresh display despite the unchanged handle");
932        assert!(
933            moved.bbox.min[0] < 15.0,
934            "the fresh display sits at the SOLVED pose, not the authored one: {:?}",
935            moved.bbox
936        );
937
938        // A third identical run is a settled no-motion solve: no write-backs, and
939        // the moved solid goes back to reusing its (now up-to-date) display.
940        let third = runner.run(&with_constraint);
941        assert!(third.assembly_poses.is_empty(), "{:?}", third.assembly_poses);
942        assert!(third.moved_solids.is_empty());
943        assert!(third.snapshot.iter().all(|(_, _, d)| d.is_none()), "settled run reuses everything");
944    }
945
946    /// A `RunOutput` — the whole delta (tessellated `SolidDisplay` snapshot + the
947    /// report's frames/profiles/provenance) — survives a serde JSON round trip.
948    /// This is the wasm WorkerRunner (M3) message payload: the worker serializes
949    /// the run result and the main thread reconstructs it byte-for-byte.
950    #[test]
951    fn run_output_round_trips_through_serde() {
952        // A cube (→ a tessellated SolidDisplay) + a DATUM (→ frames) + a committed
953        // rectangle SKETCH (→ a profile) exercise every side-channel of the report.
954        let request: HistoryRequest = serde_json::from_str(
955            r#"{
956                "expressions": "", "configurator": {},
957                "features": [
958                    { "type": "P.CU", "inputParams": { "id": "Box", "sizeX": 10, "sizeY": 10, "sizeZ": 10,
959                        "transform": { "position": [0,0,0], "rotationEuler": [0,0,0], "scale": [1,1,1] },
960                        "boolean": { "targets": [], "operation": "NONE" } }, "persistentData": {} },
961                    { "type": "D", "inputParams": { "id": "Datum" }, "persistentData": {} },
962                    { "type": "S", "inputParams": { "id": "Sk" }, "persistentData": {
963                        "basis": { "origin": [0,0,0], "x": [1,0,0], "y": [0,1,0], "z": [0,0,1] },
964                        "sketch": { "points": [
965                            {"id":0,"x":0,"y":0},{"id":1,"x":10,"y":0},{"id":2,"x":10,"y":6},{"id":3,"x":0,"y":6}],
966                          "geometries": [
967                            {"id":10,"type":"line","points":[0,1]},{"id":11,"type":"line","points":[1,2]},
968                            {"id":12,"type":"line","points":[2,3]},{"id":13,"type":"line","points":[3,0]}],
969                          "constraints": [] } } }
970                ]
971            }"#,
972        )
973        .unwrap();
974
975        let mut output = SceneRunner::new().run(&request);
976        // Seed the ASSEMBLY side-channels non-empty: the wasm WorkerRunner is the
977        // ONE path that serializes RunOutput (ThreadRunner ships structs over
978        // mpsc), and `assembly_poses` is load-bearing there — if it dropped on
979        // the wire, the pose-authority fold would silently never happen in the
980        // browser while every native test stayed green.
981        output.assembly_poses = vec![(
982            "ACOMP2".to_string(),
983            serde_json::json!({ "translate": [1.0, 2.0, 3.0], "rotateEulerDeg": [0.0, 0.0, 90.0] }),
984        )];
985        output.assembly_fixed = vec![("ACOMP1".to_string(), true)];
986        output.moved_solids = vec!["ACOMP2:Part".to_string()];
987        let output = output;
988        // Sanity: the run produced the box display + the datum frames + the profile.
989        assert_eq!(output.snapshot.len(), 1, "one solid (the box)");
990        assert!(output.snapshot[0].2.as_ref().unwrap().mesh.positions.len() > 0);
991        assert!(output.report.frames.iter().any(|(n, _)| n == "Datum:XY"));
992        assert!(output.report.profiles.iter().any(|(n, _)| n == "Sk"));
993        assert!(output.provenance.iter().any(|(n, id)| n == "Box" && id == "Box"));
994        // Pre-serialization sanity so the round-trip assert below can't pass
995        // vacuously on an empty vec: the cube's faces originate from `Box`.
996        assert!(
997            output.entity_origin.iter().any(|(_, id)| id == "Box"),
998            "entity_origin should carry the box faces' origin"
999        );
1000
1001        let json = serde_json::to_string(&output).expect("RunOutput serializes");
1002        let back: RunOutput = serde_json::from_str(&json).expect("RunOutput deserializes");
1003
1004        assert_eq!(back.snapshot.len(), output.snapshot.len());
1005        assert_eq!(back.snapshot[0].0, "Box");
1006        assert_eq!(
1007            back.snapshot[0].2.as_ref().unwrap().mesh.positions.len(),
1008            output.snapshot[0].2.as_ref().unwrap().mesh.positions.len()
1009        );
1010        assert!(back.report.frames.iter().any(|(n, _)| n == "Datum:XY"));
1011        let (name, profile) = back.report.profiles.iter().find(|(n, _)| n == "Sk").unwrap();
1012        assert_eq!(name, "Sk");
1013        assert_eq!(profile.regions.len(), 1, "the rectangle profile survived");
1014        // The sketch's per-segment PATHS cross the seam beside its profile — the
1015        // committed-sketch display draws an OPEN sketch from them, so a dropped
1016        // field would make open sketches invisible on the threaded-runner path only.
1017        assert!(
1018            back.report.paths.iter().any(|(name, _)| name == "Sk:G10"),
1019            "the sketch's per-segment paths survive the round trip: {:?}",
1020            back.report.paths.iter().map(|(n, _)| n).collect::<Vec<_>>()
1021        );
1022        assert_eq!(back.report.paths.len(), output.report.paths.len());
1023        assert_eq!(back.provenance, output.provenance);
1024        // The entity-origin map crosses the worker seam too — a dropped field would
1025        // silently lose face/edge origins on the threaded-runner path only.
1026        assert_eq!(back.entity_origin, output.entity_origin);
1027        // The assembly side-channels cross the worker seam (see the seed above).
1028        assert_eq!(back.assembly_poses, output.assembly_poses);
1029        assert_eq!(back.assembly_fixed, output.assembly_fixed);
1030        assert_eq!(back.moved_solids, output.moved_solids);
1031        // And a pre-assembly serialized reply (no such keys) still deserializes
1032        // — the `#[serde(default)]` compatibility contract.
1033        let legacy: RunOutput = serde_json::from_str(
1034            r#"{"snapshot":[],"report":{"feature_errors":[],"unresolved":[],"display_errors":[],"feature_timings":[],"feature_outputs":[],"frames":[],"profiles":[]},"provenance":[],"entity_origin":[]}"#,
1035        )
1036        .expect("legacy reply deserializes");
1037        assert!(legacy.assembly_poses.is_empty() && legacy.moved_solids.is_empty());
1038        // ...including a reply from before the report carried `paths` at all: an
1039        // in-flight worker message of the OLD shape still reads, with no paths.
1040        assert!(legacy.report.paths.is_empty() && legacy.report.axes.is_empty());
1041    }
1042}