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