Skip to main content

brep_render/engine_state/
history_ops.rs

1use super::*;
2
3// --- Scene feed (R10) -------------------------------------------------
4
5impl EngineState {
6    /// Run a whole history and reconcile the display scene (R10 incremental):
7    /// reused solids keep their buffers, the rest re-tessellate. Returns the build
8    /// report JSON (`{featureErrors, unresolved, displayErrors}`). Marks dirty.
9    ///
10    /// A SCENE-ONLY one-shot: it does not touch the engine-owned history document
11    /// and does not run the `Self::finish_apply` tail, so it also does not paint
12    /// model colours. Callers that need those use [`Self::set_history_json`].
13    pub fn run_history_json(&mut self, request_json: &str) -> Result<String, String> {
14        let request: HistoryRequest = serde_json::from_str(request_json)
15            .map_err(|error| format!("history request parse: {error}"))?;
16        let report = self.run_plugin_scene(&request)?;
17        self.dirty = true;
18        Ok(with_auto_targets(with_approximations(
19            with_refusals(
20                serde_json::json!({
21                    "featureErrors": report.feature_errors,
22                    "featureNotes": report.feature_notes,
23                    "featureFulfilment": fulfilment_map(&report),
24                    "unresolved": report.unresolved,
25                    "displayErrors": report.display_errors,
26                }),
27                &report,
28            ),
29            &report,
30        ), &report)
31        .to_string())
32    }
33
34    /// (Re)run the current rolled-to prefix of the engine's history through the
35    /// SAME kernel pipeline and reconcile the display scene. Stores + returns the
36    /// build report JSON.
37    pub(super) fn rerun_history(&mut self) -> String {
38        self.rerun_history_forcing(None)
39    }
40
41    pub(super) fn rerun_history_forcing(&mut self, feature_id: Option<&str>) -> String {
42        if self.defer_plugin_run() { return self.history_report.clone(); }
43        // This run publishes whatever a symbol edit did to the PORT features,
44        // so `pump` need not run again for it.
45        self.history.take_ports_followed();
46        // Keep the incremental cache when rolling or editing: producer entries
47        // own their handles, so rolling before a consuming boolean can reuse its
48        // inputs. Document switches clear the cache in `set_history_json`.
49        // Tag each submission with a generation; `pump` applies completed deltas
50        // immediately for the inline runner or on a later frame for workers.
51        // A transient preview replaces what the run evaluates, in the request
52        // only: the history keeps its own source.
53        self.drop_stale_expression_preview();
54        let request_value = self.run_request_value();
55        match serde_json::from_value::<HistoryRequest>(request_value) {
56            Ok(mut request) => {
57                if let Some(id) = feature_id {
58                    request.force_rebuild.push(id.to_string());
59                }
60                // Carry the live display LOD to the runner (the request is the run
61                // boundary the thread/worker receives). The runner re-tessellates
62                // every resident mesh when this differs from its last run's lod.
63                request.display_lod = self.settings.lod_factor;
64                self.run_generation += 1;
65                // A new run supersedes a cancelled one's notice.
66                self.cancelled_run = None;
67                // The PARTS-LIBRARY CHANNEL. A background runner (native
68                // thread / browser worker) owns its own kernel store, so it
69                // needs the library — but sending it WITH every run meant
70                // stringifying every embedded part payload on the UI thread
71                // for every edit, which froze the browser on an imported STEP
72                // assembly. It is sent only when it CHANGES; `fetch` does not
73                // run in the steady state. Inline shares this thread's store
74                // and no-ops.
75                self.runner.sync_parts_library(
76                    brep_kernel::parts_library_revision(),
77                    &mut brep_kernel::parts_library_map,
78                );
79                self.sync_plugin_runner();
80                self.runner.submit_run(request, self.run_generation);
81            }
82            // A parse failure runs nothing: clear the surfaced frames/profiles (so
83            // stale construction geometry does not linger — the scene keeps its
84            // previous solids) and set an error report, then run the shared
85            // post-apply tail synchronously (no kernel work), so this branch shares
86            // the dirty/gizmo/overlay continuation verbatim with a real apply.
87            Err(error) => {
88                self.construction_frames.clear();
89                self.sketch_profiles.clear();
90                self.sketch_paths.clear();
91                self.sketch_points.clear();
92                self.sketch_axes.clear();
93                self.wire_harness_report = None;
94                self.ports_report = None;
95                self.finish_apply(
96                    serde_json::json!({ "error": format!("history request: {error}") }).to_string(),
97                );
98            }
99        }
100        // Inline applies the submitted run NOW; a thread impl would defer it to a
101        // later frame's `pump`. Either way `history_report` is fresh once the reply
102        // is applied — for Inline that is before this call returns.
103        self.pump();
104        self.history_report.clone()
105    }
106
107    /// Drain every completed run reply and APPLY it — the POLL/APPLY half of the
108    /// M2a seam. Called from `rerun_history` for the Inline
109    /// runner's immediate apply, and once per frame from the app so a future async
110    /// runner's completed runs land on the main thread. A reply older than
111    /// [`applied_generation`](Self::applied_generation) (a newer run that finished
112    /// first) is dropped.
113    pub fn pump(&mut self) {
114        self.pump_with(false);
115    }
116
117    /// [`pump`](Self::pump) leaving a run reply, and a refusal asking for the
118    /// parts library, for a later pump: everything else drains. For a
119    /// background document whose library is not installed this frame (see
120    /// [`run_landing`](Self::run_landing)); a reply that arrives meanwhile
121    /// waits for the next frame, which installs the library first.
122    pub fn pump_holding_runs(&mut self) {
123        self.pump_with(true);
124    }
125
126    fn pump_with(&mut self, hold_runs: bool) {
127        self.history.begin_frame();
128        // A user edit refused because an agent's staged feature is evaluating
129        // is said, once per refusal, rather than silently dropped.
130        if self.history.busy_refused() > self.busy_refused_seen {
131            self.busy_refused_seen = self.history.busy_refused();
132            self.push_notice(format!(
133                "Not applied: {}. Try again when it has finished.",
134                self.history.busy().unwrap_or("an agent's change to this document is still evaluating")
135            ));
136        }
137        self.pump_plugin_installations();
138        // The runner REFUSED a run because its resident parts library could not
139        // serve it (see `Reply::NeedPartsLibrary`). It has already forgotten
140        // its copy, so re-running re-installs the library and re-submits. This
141        // is a real path, not just a tripwire: the kernel's orphan GC drops
142        // entries at the end of every run, so an undo to zero components empties
143        // the RUNNER's store while this side's (which never ran) keeps
144        // everything — no revision bookkeeping can see that, only the runner's
145        // content preflight can. `library_resync` breaks the rerun→pump→rerun
146        // recursion (the reinstall makes the second attempt succeed, but a
147        // guard beats relying on that). The guard is tested FIRST because
148        // `poll_library_request` CONSUMES the flag — polling it while a resync
149        // is already in flight would swallow a second refusal.
150        if !hold_runs && !self.library_resync && self.runner.poll_library_request() {
151            self.library_resync = true;
152            self.rerun_history();
153            self.library_resync = false;
154        }
155        // A symbol edit relabelled, added or removed a PORT feature. The eCAD
156        // editors write their blocks straight into the history, which does not
157        // run it, so the run happens here, on the next frame, for every host.
158        if self.history.take_ports_followed() {
159            self.rerun_history();
160        }
161        // Phases arrive before their import's final reply; only a preview
162        // asks for them.
163        while let Some(phase) = self.runner.poll_mesh_import_phase() {
164            if self.pending_mesh_imports.get(&phase.id) == Some(&MeshImportDestination::Preview) {
165                self.mesh_preview_phases.push_back(phase);
166            }
167        }
168        while let Some(reply) = self.runner.poll_region_fit() {
169            self.region_fit_results.push_back(reply);
170        }
171        while let Some(reply) = self.runner.poll_apply_region_choices() {
172            self.apply_choices_results.push_back(reply);
173        }
174        while let Some(reply) = self.runner.poll_mesh_import() {
175            // A document switch clears this set. Ignore any older reconstruction
176            // reply that was already running when its Reset crossed the queue.
177            let Some(destination) = self.pending_mesh_imports.remove(&reply.id) else {
178                continue;
179            };
180            if destination == MeshImportDestination::Preview {
181                self.mesh_preview_results.push_back(reply);
182                continue;
183            }
184            match reply.result {
185                Ok(output) => match self.import_step_feature(&output.step_text) {
186                    Ok(_) => {
187                        self.push_notice(
188                            "RANSAC reconstruction complete; building imported CAD body",
189                        );
190                        // A body the chain refused is left out of the model;
191                        // say so, by name, rather than let it vanish.
192                        if let Some(notice) = crate::runner::refused_bodies_notice(&output.report)
193                        {
194                            self.push_notice(notice);
195                        }
196                    }
197                    Err(error) => self.push_notice(format!("mesh import failed: {error}")),
198                },
199                Err(error) => self.push_notice(format!("mesh import failed: {error}")),
200            }
201        }
202        // STEP probes: the parse ran on the runner; stash the structure it
203        // found (the import consumes it) and queue the outcome for the panel.
204        while let Some(reply) = self.runner.poll_step_probe() {
205            if !self.pending_step_probes.remove(&reply.id) {
206                continue; // cancelled, or a document switch — nobody is waiting
207            }
208            let outcome = match reply.result {
209                Ok(Some(assembly)) => {
210                    let probe = super::model_io::probe_counts(&assembly);
211                    // The kernel already refuses a structure that reaches no
212                    // geometry, so this is belt-and-braces: an assembly with
213                    // zero instances would import as zero components, which
214                    // is the silent failure the structured lane forbids.
215                    if probe.instances == 0 {
216                        super::StepProbeOutcome::Flat
217                    } else {
218                        self.pending_step_assembly = Some(assembly);
219                        super::StepProbeOutcome::Structure(probe)
220                    }
221                }
222                Ok(None) => super::StepProbeOutcome::Flat,
223                Err(error) => super::StepProbeOutcome::Failed(error),
224            };
225            self.step_probe_results.push_back((reply.id, outcome));
226        }
227        // The in-flight run's latest progress report (a background runner
228        // posts one before each feature it executes). Kept only for a run
229        // newer than the applied one: a report from a superseded run — or one
230        // still arriving after a cancel bumped the generations — is stale.
231        if let Some(progress) = self.runner.poll_progress() {
232            if progress.generation > self.applied_generation {
233                self.run_progress = Some(progress);
234            }
235        }
236        self.pump_plugin_callbacks();
237        while let Some(reply) = if hold_runs { None } else { self.runner.poll_run() } {
238            self.runs_replied += 1;
239            if reply.generation >= self.run_generation && self.accept_plugin_run(&reply) {
240                self.applied_generation = reply.generation;
241                self.apply_run_output(reply.output);
242            } else {
243                // Superseded — but the runner's baseline has moved past every
244                // display in it, so a later keep can name one of them. Hold
245                // them for that keep (see `superseded_displays`).
246                for (name, _handle, maybe) in reply.output.snapshot {
247                    if let Some(display) = maybe {
248                        self.superseded_displays.insert(name, display);
249                    }
250                }
251            }
252        }
253        // The run just applied released sketch links the kernel could not
254        // resolve, editing the history after the fact: run it again so the
255        // report matches the document. Once, since the second run finds no
256        // listed edge left to release (the inline runner applies it before
257        // this returns; a background runner lands it on a later frame).
258        if std::mem::take(&mut self.sketch_links_released) {
259            self.rerun_history();
260        }
261        // A component replace classifies its mates once its run has landed.
262        if self.replace_pending.is_some() {
263            self.finish_component_replace();
264        }
265        if !self.run_pending() {
266            self.run_progress = None;
267        }
268        // Deferred one-shot framing (Import / Open): frame the scene the moment the
269        // run they submitted has fully landed. Consumed unconditionally once the run
270        // is no longer pending — even when it produced no solids (bbox empty →
271        // `zoom_to_fit` no-ops) — so a later unrelated run never inherits a stale fit.
272        if self.pending_fit && !self.run_pending() {
273            self.pending_fit = false;
274            self.zoom_to_fit();
275        }
276        if self.instructions_wizard_pending && !self.run_pending() {
277            self.instructions_wizard_pending = false;
278            if let Err(error) = self.instructions_run_wizard() {
279                self.push_notice(format!("The instructions wizard could not run: {error}"));
280            }
281        }
282        if self.instructions_views_pending && !self.run_pending() {
283            self.instructions_views_pending = false;
284            if let Err(error) = self.instructions_refresh_views() {
285                self.push_notice(format!("The re-targeted steps' views could not be made: {error}"));
286            }
287        }
288        // Drain any completed measurement replies too (a background runner surfaces
289        // them a frame after selection); for Inline this is a no-op each frame since
290        // `object_info_json` already pumped its own query same-call.
291        self.pump_queries();
292        self.pump_topology();
293        self.pump_sheet_lines();
294        // The CAM generation queue: progress and replies, then the next job.
295        self.cam_pump();
296    }
297
298    /// Ask the runner for the B-rep topology of the displayed solids `names`
299    /// that the scene does not hold for their current handle and that have not
300    /// been asked for since the last applied run, then drain whatever has
301    /// already answered (all of it, for the synchronous Inline runner). The
302    /// drawing sheet's door, and the balloon bubble drag's (which re-projects
303    /// the arrow head on the exact solid, `pmi_reproject_balloon_head`):
304    /// nothing else needs topology on this side, so a document with neither a
305    /// sheet nor a dragged balloon never asks.
306    pub fn request_exact_solids<'a>(&mut self, names: impl IntoIterator<Item = &'a str>) {
307        let mut wanted: Vec<(String, u32)> = Vec::new();
308        for name in names {
309            let Some(display) = self.scene.solid(name) else { continue };
310            let handle = display.source_handle;
311            if handle == 0 || display.is_sketch || self.scene.exact_solid(name).is_some() {
312                continue;
313            }
314            if self.topology_asked.insert(handle) {
315                wanted.push((name.to_string(), handle));
316            }
317        }
318        if !wanted.is_empty() {
319            self.next_topology_id += 1;
320            let id = self.next_topology_id;
321            self.pending_topology.insert(id);
322            self.runner.submit_topology(crate::runner::TopologyRequest { id, solids: wanted });
323        }
324        self.pump_topology();
325    }
326
327    /// Fold every answered topology request into the scene. A reply for a
328    /// display that has moved on to another handle is dropped; the next
329    /// projection asks for the new one.
330    pub fn pump_topology(&mut self) {
331        let mut landed = false;
332        while let Some(reply) = self.runner.poll_topology() {
333            if !self.pending_topology.remove(&reply.id) {
334                continue; // cancelled, or a document switch
335            }
336            for (name, handle, solid) in reply.solids {
337                self.scene.set_exact_solid(&name, handle, solid);
338            }
339            landed = true;
340        }
341        if landed {
342            self.pmi_refresh_stale_balloon();
343        }
344    }
345
346    /// Whether a topology request is still in flight — the frame loop and the
347    /// idle contract wait on it like a measurement query.
348    pub fn topology_pending(&self) -> bool {
349        !self.pending_topology.is_empty()
350    }
351
352    /// Whether a measurement query is still in flight (its reply not yet drained) —
353    /// the query analogue of [`run_pending`](Self::run_pending), so the app keeps the
354    /// frame loop alive until a background runner's measurement lands and displays.
355    /// Always `false` for the synchronous Inline runner.
356    pub fn queries_pending(&self) -> bool {
357        !self.pending_query.is_empty()
358    }
359
360    /// Whether RANSAC reconstruction is still executing on the native runner
361    /// thread or browser worker.
362    pub fn mesh_imports_pending(&self) -> bool {
363        !self.pending_mesh_imports.is_empty()
364    }
365
366    /// Whether the run in flight has something to land this frame: its reply,
367    /// a refusal to re-submit, or plugin work (which is not told apart). A
368    /// background document's [`pump`](Self::pump) needs its own parts
369    /// library installed only then.
370    pub fn run_landing(&mut self) -> bool {
371        self.run_pending() && (self.plugin_work_pending() || self.runner.run_reply_waiting())
372    }
373
374    /// Whether a submitted run has not yet been applied (`run_generation !=
375    /// applied_generation`). Always `false` for the synchronous Inline runner
376    /// (submit → immediate `pump` keeps the two in lockstep); a background runner
377    /// uses it to keep the frame loop alive until its reply lands.
378    pub fn run_pending(&self) -> bool {
379        self.run_generation != self.applied_generation || self.plugin_work_pending()
380    }
381
382    /// The generation of the last APPLIED run — bumps once per applied history
383    /// run (document loads, edits, constraint mutations, solves). A cheap
384    /// staleness key for app-side caches derived from the applied document
385    /// (the update-components outdated badge keys on it).
386    pub fn applied_generation(&self) -> u64 {
387        self.applied_generation
388    }
389
390    /// What the in-flight run is executing right now, as far as the runner
391    /// has reported (see [`crate::runner::RunProgress`]); `None` when nothing
392    /// is running or the run has not reached its first executed feature.
393    pub fn run_progress(&self) -> Option<&crate::runner::RunProgress> {
394        self.run_progress.as_ref()
395    }
396
397    /// The feature id the last cancelled run was executing (empty when it was
398    /// cancelled before any progress arrived), until the next submit.
399    pub fn cancelled_run(&self) -> Option<&str> {
400        self.cancelled_run.as_deref()
401    }
402
403    /// CANCEL the in-flight run. The runner abandons its work and comes back
404    /// with an EMPTY resident registry (a fresh thread / a fresh worker — see
405    /// [`crate::runner::HistoryRunner::cancel`]), so this side forgets
406    /// everything that was waiting on it: the run itself (generations are
407    /// bumped past it, so a straggling reply from the old runner is dropped
408    /// as stale), pending measurement queries, document-bound mesh imports
409    /// and a deferred fit. The DISPLAY SCENE is left as the last applied run
410    /// built it — the document is ahead of it now, which the notice says; the
411    /// next edit re-runs the whole history through the new runner (a cold
412    /// run: the warm cache went with the old one). Nothing inside a feature
413    /// is interruptible, so the native thread keeps burning CPU until the
414    /// feature it is on finishes; the browser worker is terminated outright.
415    ///
416    /// `false` when nothing was running, or the runner cannot abandon (the
417    /// synchronous Inline runner, whose runs are over before anyone can ask).
418    pub fn cancel_run(&mut self) -> bool {
419        self.cancel_run_with_notice(true)
420    }
421
422    pub(super) fn cancel_run_with_notice(&mut self, notify: bool) -> bool {
423        if self.plugin_installation_pending() { return false; }
424        if self.cancel_plugin_action() { return true; }
425        if !self.run_pending() || !self.runner.cancel() {
426            return false;
427        }
428        let stalled_on = self.run_progress.take().map(|progress| progress.feature_id);
429        // Past every generation submitted so far: a reply the old runner
430        // already posted (sitting in the main event loop on wasm) carries an
431        // older number than this and is dropped by `pump`'s gate.
432        self.run_generation += 1;
433        self.applied_generation = self.run_generation;
434        self.pending_query.clear();
435        // The new runner's registry counts handles from one again, so every
436        // topology keyed by an old handle could alias a new display.
437        self.pending_topology.clear();
438        self.topology_asked.clear();
439        self.scene.clear_exact_solids();
440        self.sheet_lines.forget();
441        // The old runner's held displays name ITS handles; the new one's
442        // first run emits every display afresh.
443        self.superseded_displays.clear();
444        let dropped_imports = self.pending_mesh_imports.len();
445        self.pending_mesh_imports.clear();
446        let dropped_probes = self.pending_step_probes.len();
447        self.pending_step_probes.clear();
448        self.pending_fit = false;
449        self.instructions_wizard_pending = false;
450        self.instructions_views_pending = false;
451        self.library_resync = false;
452        self.sketch_links_released = false;
453        self.cancelled_run = Some(stalled_on.clone().unwrap_or_default());
454        if notify {
455            self.push_notice(match stalled_on {
456                Some(id) if !id.is_empty() => format!(
457                    "Run cancelled while executing {id}. The model shows the last completed \
458                     result; edit or delete the feature to rebuild."
459                ),
460                _ => "Run cancelled. The model shows the last completed result; the next edit \
461                      rebuilds it."
462                    .to_string(),
463            });
464        }
465        if dropped_imports > 0 {
466            self.push_notice(format!(
467                "cancelled {dropped_imports} pending mesh import{}",
468                if dropped_imports == 1 { "" } else { "s" }
469            ));
470        }
471        if dropped_probes > 0 {
472            self.push_notice("cancelled the STEP file being read — upload it again to import it");
473        }
474        true
475    }
476
477    /// How many run replies the runner has sent back (see `runs_replied`).
478    pub fn runs_replied(&self) -> u64 {
479        self.runs_replied
480    }
481
482    /// Whether the display scene currently holds at least one solid. Used by the
483    /// app's async-safe first-frame framing: under a background runner (thread /
484    /// worker) the seed run lands a frame (or many) after boot, so the shell waits
485    /// for `has_solids() && !run_pending()` before its one-shot `zoom_to_fit`.
486    pub fn has_solids(&self) -> bool {
487        !self.scene.solids().is_empty()
488    }
489
490    /// Swap in a different history runner (the platform injects its own — the native
491    /// app installs a [`ThreadRunner`](crate::runner::ThreadRunner); wasm keeps the
492    /// default Inline until M3's worker). Resets the new runner's delta baseline so
493    /// the next run rebuilds fully. Call BEFORE seeding a document so the seed builds
494    /// through the installed runner.
495    pub fn set_runner(&mut self, runner: Box<dyn crate::runner::HistoryRunner>) {
496        self.runner = runner;
497        self.plugin_runner_replaced();
498        self.runner.reset();
499        self.superseded_displays.clear();
500        self.pending_mesh_imports.clear();
501        self.mesh_preview_results.clear();
502        self.pending_topology.clear();
503        self.topology_asked.clear();
504        self.scene.clear_exact_solids();
505        self.sheet_lines.forget();
506    }
507
508    /// Apply a [`SceneRunner`](crate::pipeline::SceneRunner) delta to the display
509    /// scene and build the history report JSON — the APPLY half of the M2a seam.
510    ///
511    /// Reconcile preserving ORDER + reuse: MOVE every current display out of the
512    /// scene ([`RenderScene::drain`](crate::scene::RenderScene::drain)) into a
513    /// name-keyed `kept` map, then reinsert in snapshot order — a fresh entry
514    /// (`Some`) replaces, an UNCHANGED entry (`None`) reuses its moved-out display
515    /// (stable `revision` ⇒ GPU-buffer reuse, Task-1; its `source_handle` equals
516    /// the run's handle by the monotonic-handle reuse invariant). Leftovers in
517    /// `kept` — departed kernel solids AND the previous run's sketch sheets — are
518    /// dropped; `refresh_committed_sketches` (run in the shared continuation after)
519    /// re-adds the sheets, so dropping them here is correct.
520    ///
521    /// Then the report continuation (identical to the pre-seam run): keep the run's
522    /// resolved frames + solved sketch profiles and fold the per-feature timings /
523    /// output-names into the id-keyed report JSON, then hand it to
524    /// [`finish_apply`](Self::finish_apply) — the shared dirty/gizmo/overlay tail
525    /// that the parse-error branch in [`rerun_history`](Self::rerun_history) also
526    /// calls, so both paths share the continuation verbatim.
527    pub(super) fn apply_run_output(&mut self, output: crate::pipeline::RunOutput) {
528        let crate::pipeline::RunOutput {
529            snapshot,
530            mut report,
531            provenance,
532            entity_origin,
533            assembly_poses,
534            assembly_fixed,
535            moved_solids,
536            imported_colors,
537            consumed,
538            assembly,
539        } = output;
540        for (id, data) in &report.plugin_persistent_data {
541            self.history.fold_plugin_persistent_data(id, data.clone());
542        }
543        // The runner already forced fresh displays for the solver-moved solids
544        // (their snapshot entries arrive `Some`); nothing extra to do main-side.
545        let _ = moved_solids;
546        // Un-pose the active PMI view's exploded displays BEFORE the reconcile
547        // keeps them, so the re-pose after apply starts from the modeling
548        // pose and never compounds. A live component drag's poses go first
549        // (they were applied last).
550        self.component_live_restore();
551        self.pmi_restore_explode();
552        // A motion study's posed frame goes too (re-posed after apply).
553        self.simulation_restore_displays();
554
555        // The names this run consumed, for the committed-sketch refresh in the
556        // tail below — the runner folded them, so the UI thread does not execute
557        // the history a second time to fold them again.
558        self.consumed_names = consumed.into_iter().collect();
559        // Adopt the run's assembly tail. The PARTS LIBRARY comes out of it here
560        // rather than in `adopt_assembly_sync`, so the (potentially megabyte)
561        // store is installed and mirrored into the document exactly once, on the
562        // run that moved it, and never kept in the snapshot afterwards.
563        self.assembly_sync = assembly.map(|mut sync| {
564            if let Some(library) = sync.parts_library.take() {
565                // CLEAR, then install. `install_parts_library` keeps a resident
566                // entry whose identity (source key + signature + document hash)
567                // matches the incoming one VERBATIM — the right rule for the
568                // main → runner direction it was written for, because the
569                // runner may hold a heal the sender never saw. This is the
570                // other direction: the run that just healed an entry (a heal
571                // rewrites the snapshot and deliberately does NOT change the
572                // identity) is the one holding the newer copy, so keeping the
573                // resident one would silently drop it and the document would
574                // re-heal on every open. Emptying first makes every incoming
575                // entry a fresh insert, i.e. "the store IS what the run left".
576                brep_kernel::install_parts_library(&Default::default());
577                brep_kernel::install_parts_library(&library);
578                if let Ok(block) = serde_json::to_value(&library) {
579                    self.history.set_parts_library(block);
580                }
581            }
582            sync
583        });
584
585        // --- assembly pose-authority fold -------- Adopt the solver's pose /
586        // isFixed write-backs into the owning ACOMP features by
587        // `inputParams.id` BEFORE anything persists or re-runs this document.
588        // Deliberately NOT `update_feature_params`: the fold must not mint an
589        // undo entry nor trigger a rerun (rerun → solve → fold → rerun would
590        // loop); `fold_param_no_undo` writes the param silently, and the next
591        // run's request simply carries the solved pose (a no-motion solve emits
592        // no updates, so fingerprints never churn).
593        for (id, pose) in assembly_poses {
594            self.fold_assembly_pose(&id, pose);
595        }
596        // A legacy Fixed row migrated by the run: the instance setting it
597        // became (`isFixed`, or `flexibleFixed[path]` on the flexible instance
598        // that lends an occurrence `ACOMP1:ACOMP2`).
599        for (id, fixed) in assembly_fixed {
600            self.fold_assembly_fixed(&id, fixed);
601        }
602
603        // Adopt the run's eager provenance (SOLID last-writer) + entity origin
604        // (face/edge first-writer) wholesale (both drive `creating_feature` + the
605        // Info tab's `creatingFeature` with no cold re-run), and INVALIDATE the
606        // object-info measurement cache + any in-flight query: the geometry changed,
607        // so cached measurements are stale and a pending reply is superseded (a
608        // re-selection re-queries against the fresh geometry).
609        self.provenance = provenance.into_iter().collect();
610        self.entity_origin = entity_origin.into_iter().collect();
611        self.info_cache.clear();
612        self.pending_query.clear();
613
614        // Fold the run's IMPORTED COLOURS into the engine's own metadata store —
615        // the seam between the kernel's (thread-local, never persisted by us)
616        // name-keyed store and the one the Info window edits and the document
617        // saves. NON-overwriting on purpose: an import re-stamps its colour on
618        // every replay, and a colour the user changed in the panel must win.
619        for (name, hex) in imported_colors {
620            if self.metadata.attribute(&name, "color").is_none() {
621                self.metadata.set_attribute(&name, "color", &hex);
622            }
623        }
624
625        // Reconcile the scene: move current displays out, reinsert in order.
626        let mut kept: std::collections::HashMap<String, crate::scene::SolidDisplay> = self
627            .scene
628            .drain()
629            .into_iter()
630            .map(|solid| (solid.name.clone(), solid))
631            .collect();
632        for (name, handle, maybe) in snapshot {
633            match maybe {
634                Some(display) => {
635                    self.superseded_displays.remove(&name);
636                    self.scene.insert_solid(display);
637                }
638                // "Unchanged": the runner last emitted `handle` for this name.
639                // That emit is the scene's display when its reply was applied,
640                // and a held superseded display when it was dropped — the
641                // handle says which, since handles are monotonic and never
642                // recycled within one runner. Neither holding it is a protocol
643                // breach (a reply lost between the runner and `pump`), and the
644                // app is not the place to crash on one, in any build: the
645                // solid is left out of this frame and the report names it.
646                None => {
647                    let kept_display = kept.remove(&name);
648                    let display = match kept_display {
649                        Some(display) if display.source_handle == handle => Some(display),
650                        _ => self
651                            .superseded_displays
652                            .remove(&name)
653                            .filter(|display| display.source_handle == handle),
654                    };
655                    match display {
656                        Some(display) => self.scene.insert_solid(display),
657                        None => {
658                            report.display_errors.push(format!(
659                                "{name}: the run kept resident handle {handle}, which no reply has displayed"
660                            ));
661                        }
662                    }
663                }
664            }
665        }
666        // Topology is kept only for a display whose handle survived the run,
667        // and so is the record of what was asked: a surviving handle is in
668        // flight, answered or not in the registry, and every other is gone.
669        self.scene.prune_exact_solids();
670        let live: std::collections::HashSet<u32> =
671            self.scene.solids().iter().map(|solid| solid.source_handle).collect();
672        self.topology_asked.retain(|handle| live.contains(handle));
673
674        // Keep every plane frame the run resolved (DATUM/PLANE/SKETCH);
675        // `refresh_construction_datums` filters to the D/P producers.
676        self.construction_frames = report.frames.clone();
677        // Keep every solved sketch profile so `refresh_committed_sketches` can
678        // synthesize the committed sketch sheet solids.
679        self.sketch_profiles = report.profiles.clone();
680        // ...and every path chain, so a sketch whose geometry closes NO region (an
681        // open chain — the reported single line) still has something to draw.
682        self.sketch_paths = report.paths.clone();
683        // ...and every published point, so a sketch holding ONLY points (a
684        // hole-placement sketch) still has something to draw.
685        self.sketch_points = report.points.clone();
686        // Keep every axis line the run published so the angle gizmo can resolve a
687        // revolve `axis` reference to a world line without re-running.
688        self.sketch_axes = report.axes.clone();
689        // ...and the wire-harness routing report, for the harness panel.
690        self.wire_harness_report = report.wire_harness.clone();
691        // ...and the ports tail's report, for the Qualify panel: the tail is not
692        // a feature, so nothing else carries what it refused or could not resolve.
693        self.ports_report = report.ports.clone();
694        // ...and the PMI report (every view's resolved annotations).
695        self.pmi_report = report.pmi.clone();
696        self.fold_plugin_annotation_data();
697        // Fold the per-feature timing / output-name pairs into id-keyed maps so the
698        // history-tree UI can look them up by feature id.
699        let timings: serde_json::Map<String, serde_json::Value> = report
700            .feature_timings
701            .iter()
702            .map(|(id, ms)| (id.clone(), serde_json::json!(ms)))
703            .collect();
704        let outputs: serde_json::Map<String, serde_json::Value> = report
705            .feature_outputs
706            .iter()
707            .map(|(id, names)| (id.clone(), serde_json::json!(names)))
708            .collect();
709        let report_json = with_auto_targets(with_approximations(
710            with_refusals(
711                serde_json::json!({
712                    "featureErrors": report.feature_errors,
713                    "featureNotes": report.feature_notes,
714                    "featureFulfilment": fulfilment_map(&report),
715                    "unresolved": report.unresolved,
716                    "displayErrors": report.display_errors,
717                    "featureTimings": timings,
718                    "featureOutputs": outputs,
719                }),
720                &report,
721            ),
722            &report,
723        ), &report)
724        .to_string();
725        self.release_unresolved_sketch_links(&report.unresolved);
726        self.finish_apply(report_json);
727    }
728
729    /// Release every sketch edge link whose edge this run could not resolve —
730    /// the kernel's own `unresolved` word, per feature, so an edge a LATER
731    /// feature consumed (gone from the final scene, present where the sketch
732    /// runs) is never mistaken for a lost one. For each such link the sketch
733    /// keeps its geometry as ordinary fixed construction geometry
734    /// ([`crate::sketch::external_ref::release_ref_json`]), drops the
735    /// `externalRefs` entry and takes the name out of `projectedEdges`. A
736    /// listed name that never materialized has nothing to keep and stays
737    /// listed (the report keeps naming it).
738    ///
739    /// Folded like the assembly pose write-back: no undo checkpoint, so undoing
740    /// the deletion that lost the edge restores the snapshot with the link
741    /// intact, and no run from here (re-entrancy); the flag makes `pump` run
742    /// once more. Nothing happens while a sketch is being edited (the editor
743    /// owns its doc and the rolled-back run never runs it) or while the
744    /// document is locked (a refused fold would be retried every run).
745    fn release_unresolved_sketch_links(&mut self, unresolved: &[String]) {
746        if unresolved.is_empty() || self.sketch_edit.is_some() || self.history.locked().is_some() {
747            return;
748        }
749        let mut released_any = false;
750        for index in 0..self.history.len() {
751            if !self.history.feature_type(index).is_some_and(|t| matches!(t.as_str(), "S" | "SKETCH")) {
752                continue;
753            }
754            let Some(id) = self.history.feature_id(index).filter(|id| !id.is_empty()) else {
755                continue;
756            };
757            let Some(mut persistent) = self.history.feature_persistent_data(index) else {
758                continue;
759            };
760            let mut refs: Vec<crate::sketch::ExternalRef> = persistent
761                .get("externalRefs")
762                .and_then(|v| serde_json::from_value(v.clone()).ok())
763                .unwrap_or_default();
764            if refs.is_empty() {
765                continue;
766            }
767            // `"{id}: {name}"`, possibly with the kernel's rename hint appended.
768            let lost: Vec<String> = refs
769                .iter()
770                .map(|r| r.edge_name.clone())
771                .filter(|name| {
772                    let line = format!("{id}: {name}");
773                    unresolved
774                        .iter()
775                        .any(|u| *u == line || u.starts_with(&format!("{line} (renamed:")))
776                })
777                .collect();
778            if lost.is_empty() {
779                continue;
780            }
781            let mut sketch = persistent.get("sketch").cloned().unwrap_or(serde_json::Value::Null);
782            for r in refs.iter().filter(|r| lost.contains(&r.edge_name)) {
783                crate::sketch::external_ref::release_ref_json(&mut sketch, r);
784            }
785            refs.retain(|r| !lost.contains(&r.edge_name));
786            if let Some(object) = persistent.as_object_mut() {
787                if !sketch.is_null() {
788                    object.insert("sketch".into(), sketch);
789                }
790                object.insert(
791                    "externalRefs".into(),
792                    serde_json::to_value(&refs).unwrap_or_else(|_| serde_json::Value::Array(Vec::new())),
793                );
794            }
795            let listed = brep_kernel::reference_names(
796                self.history.feature_params(index).as_ref().and_then(|p| p.get("projectedEdges")),
797            );
798            let kept: Vec<serde_json::Value> = listed
799                .into_iter()
800                .filter(|name| !lost.contains(name))
801                .map(serde_json::Value::String)
802                .collect();
803            self.history.fold_plugin_persistent_data(&id, persistent);
804            self.history.fold_param_no_undo(&id, "projectedEdges", serde_json::Value::Array(kept));
805            released_any = true;
806        }
807        if released_any {
808            self.sketch_links_released = true;
809        }
810    }
811
812    /// The shared post-apply TAIL: mark dirty, store the report JSON, re-sync an
813    /// armed gizmo, and rebuild the persistent committed-sketch + construction-datum
814    /// overlays. Called after a real run's [`apply_run_output`](Self::apply_run_output)
815    /// AND from [`rerun_history`](Self::rerun_history)'s parse-error branch, so both
816    /// paths run the identical continuation. Callers read the result via
817    /// [`Self::history_report`](Self::history_report_json).
818    fn finish_apply(&mut self, report_json: String) {
819        self.dirty = true;
820        self.history_report = report_json;
821        // Keep an armed gizmo glued to its feature as the model rebuilds. During a
822        // transform drag the re-sync is driven by `transform_drag_to` itself (which
823        // resolves the delta against the frozen grab frame first, then syncs), so
824        // skip it here to avoid a redundant double-feed per drag frame. Transform
825        // mode re-feeds the widget frame; dimension mode re-projects the annotation
826        // leaders onto the rebuilt (param-changed) geometry.
827        if self.transform_gizmo.drag.is_none() {
828            match self.transform_gizmo.mode {
829                GizmoMode::Transform => self.sync_transform_gizmo(),
830                GizmoMode::Dimension => self.refresh_feature_dimension_overlay(),
831                GizmoMode::None => {}
832            }
833        }
834        // Rebuild the persistent committed-sketch overlays against the reconciled
835        // scene (also covers `set_history_json`, which returns this call's result).
836        self.refresh_committed_sketches();
837        // Rebuild the persistent construction datum/plane overlays from the frames
838        // the run just surfaced (D/P features only; sketches render as curves).
839        self.refresh_construction_datums();
840        // Rebuild the BOARD's 3D bodies from the `pcb` block, for the same reason
841        // the sketch sheets are rebuilt here: the reconcile above kept only the
842        // run's own outputs. A document with no `pcb` block returns immediately
843        // inside.
844        self.refresh_board_geometry();
845        // Assembly documents: adopt the component projection and the solved
846        // constraint state the runner shipped, and fold that state back into the
847        // document (the pose-authority contract — see `assembly_ops`).
848        // Componentless documents return immediately inside.
849        self.adopt_assembly_sync();
850        // Component selection needs the new member projection above, not the
851        // previous run's assembly. This also completes a deferred Move Copy.
852        // Keep an armed COMPONENT Move gizmo glued to its (possibly re-solved)
853        // component: re-anchor at the fresh member bbox. Never during its own
854        // drag — the drag feed owns the widget frame (free-move live-follow).
855        if self.component_move.drag.is_none() {
856            self.component_move_sync();
857        }
858        // Rebuild the assembly-constraint viewport overlays from the rows the
859        // same reply carried (an inert no-op — empty group — for a document with
860        // no assembly state). ORDER MATTERS: the overlay read must follow the
861        // adopt.
862        self.refresh_constraint_overlay();
863        // PMI: re-pose the active view's exploded solids on the fresh displays
864        // and re-bake its annotation overlay from the run's report.
865        self.pmi_after_apply();
866        // A motion study being played or scrubbed: its frame back on the
867        // fresh displays (or a re-bake if the document moved).
868        self.simulation_after_apply();
869        // Re-derive every display colour from the metadata store, LAST — after
870        // the sketch sheets, the datum overlays and the assembly sync have all
871        // settled the scene, so no display inserted above is missed. This is why
872        // a model colour now survives a feature edit: the freshly tessellated
873        // display arrives colourless and is re-coloured from the store, instead
874        // of the colour living only on the display that was just thrown away.
875        // A no-op when nothing changed, which is the common case.
876        self.sync_colors_from_metadata();
877    }
878
879    /// Load a whole history document (a saved part file parses as one); the
880    /// engine now OWNS this recipe. Rolls to the last feature and builds it.
881    ///
882    /// The document's top-level `metadata` field (the Properties-panel
883    /// name-keyed store) is lifted out into [`Self::metadata`] before the feature
884    /// list is handed to the kernel — loading a part REPLACES the store wholesale
885    /// (a document with no `metadata` clears it), mirroring the previous metadata
886    /// manager's load semantics. Round-trips with [`Self::history_request_json`].
887    ///
888    /// The top-level `workbench` field (the ACTIVE-WORKBENCH id the save embedded
889    /// — see [`Self::history_request_json`]) is lifted off the kernel recipe the
890    /// same way and applied through [`Self::apply_settings_json`] — the SAME seam
891    /// the toolbar's workbench dropdown writes through — so the palette / context
892    /// offers / workbench buttons react to a restored workbench exactly as they
893    /// do to a manual switch (settings generation bump included). Tolerances:
894    ///
895    /// * a legacy document WITHOUT the field (or with a non-string value) leaves
896    ///   the current workbench untouched — opening an old file never yanks the
897    ///   user out of their workbench;
898    /// * an unknown/stale id is stored RAW (never an error): the settings layer
899    ///   deliberately doesn't validate ids, and every consumer resolves through
900    ///   the app-side registry's `resolve()`, which falls back to the default
901    ///   workbench — so a file saved by a build with a workbench this build
902    ///   doesn't know still opens cleanly;
903    /// * the restored id is deliberately NOT persisted to the settings blob —
904    ///   that blob stays the user's boot preference; a document's workbench is
905    ///   session-scoped (the next explicit dropdown change persists as usual).
906    pub fn set_history_json(&mut self, request_json: &str) -> Result<String, String> {
907        // A new document: its toolpaths are not this one's.
908        self.cam.clear();
909        // A locked history (a PLM revision this user may not change) is not
910        // replaced either: a fresh `History` would arrive unlocked, which is the
911        // one door `History::lock` cannot hold by itself. Opening another
912        // document is a new engine, and a reload after checkout unlocks first.
913        if let Some(reason) = self.history.locked() {
914            return Err(format!("this document is read-only: {reason}"));
915        }
916        // A document switch is a wholesale model replacement: drop the incremental
917        // cache so the new model starts from a clean slate (no cross-document
918        // staleness, no unbounded cache growth across many opens). The roll/edit
919        // hot path (`rerun_history`) deliberately KEEPS the cache for instant
920        // rollback; this is the ONE place the full clear belongs.
921        brep_kernel::clear_history_cache();
922        // Reset the delta runner's baseline in lockstep with the cache clear so the
923        // new document is a FULL rebuild (no reuse against the prior model's names).
924        self.runner.reset();
925        self.superseded_displays.clear();
926        self.pending_mesh_imports.clear();
927        self.mesh_preview_results.clear();
928        self.pending_topology.clear();
929        self.topology_asked.clear();
930        self.scene.clear_exact_solids();
931        // A probed-but-unconsumed STEP assembly belongs to the document being
932        // replaced: importing it into the NEW one would land a file the user never
933        // chose here (and hold its solids resident until they did). A probe
934        // still running is likewise the old document's: its answer is dropped.
935        self.pending_step_assembly = None;
936        self.pending_step_probes.clear();
937        self.step_probe_results.clear();
938        // Everything the previous document's run shipped belongs to THAT
939        // document: the assembly tail, the component projection, its consumed
940        // names, and the main-side session's generation stamp. The new
941        // document's first reply replaces them; until it lands, nothing must
942        // read the old one's.
943        self.assembly_sync = None;
944        self.assembly_components.clear();
945        self.assembly_session_generation = None;
946        self.consumed_names.clear();
947        // The open sheet, the open sheet form and the projection cache all
948        // name objects of the document being replaced.
949        self.forget_sheets();
950        // A motion study being played or scrubbed names that document's
951        // studies and solids: its bake and posed displays go with it.
952        self.simulation_forget_playback();
953        // A preview belongs to the document being replaced.
954        self.expression_preview = None;
955        let mut document: serde_json::Value = serde_json::from_str(request_json)
956            .map_err(|error| format!("history parse: {error}"))?;
957        let thumbnail = document.as_object_mut().and_then(|o| o.remove("thumbnail"));
958        self.thumbnail_cache.replace(None);
959        // Pull `metadata` out of the document so the engine holds the single copy
960        // (kept off the History recipe the kernel executes).
961        let metadata_value = document
962            .as_object_mut()
963            .and_then(|object| object.remove("metadata"));
964        self.metadata.load_json(metadata_value.as_ref());
965        // Lift the saved active-workbench id off the kernel recipe (`metadata`'s
966        // sibling — the kernel would ignore the extra field, but the engine owns
967        // it) and apply it through the shared settings seam; see the doc comment
968        // for the legacy/unknown-id tolerances. Applied BEFORE the rebuild below
969        // so anything reading the settings post-run already sees the restored id.
970        if let Some(workbench_id) = document
971            .as_object_mut()
972            .and_then(|object| object.remove("workbench"))
973            .as_ref()
974            .and_then(serde_json::Value::as_str)
975        {
976            let _ = self.apply_settings_json(
977                &serde_json::json!({ "workbench": workbench_id }).to_string(),
978            );
979        }
980        // Stamp each sketch's per-loop ids onto its geometries before the model
981        // is built. Deriving already yields the right ids, so this renames
982        // nothing — it PERSISTS them, which is what lets a loop keep its identity
983        // when the edge the id came from is deleted. Doing it here (rather than
984        // only on sketch commit) covers every document, including one built by a
985        // script that never enters sketch mode. See the kernel's
986        // `features/sketch/loop_ids`.
987        stamp_sketch_loop_ids(&mut document);
988        // Mass / Material / Density are PART attributes: an older document
989        // that stored them on occurrences has them lifted onto the part here,
990        // before the library is installed, and the move is reported once.
991        let migration_notices =
992            super::part_mass::migrate_occurrence_part_attributes(&mut document);
993        // SEED this thread's kernel parts library from the document's block.
994        // The per-run request no longer carries the block (see
995        // `History::parts_library`), so this explicit install is the ONE door
996        // that seeds a loaded document — it is what `parts_library_json()`
997        // (SAVE), the mutation-path session re-run and the export lanes all
998        // resolve ACOMPs against, and it bumps the revision so the next run
999        // hands the fresh library to the background runner. A run that heals or
1000        // GCs it ships the result back (`apply_run_output`).
1001        let library = document
1002            .get("partsLibrary")
1003            .and_then(|block| serde_json::from_value(block.clone()).ok())
1004            .unwrap_or_default();
1005        brep_kernel::install_parts_library(&library);
1006        self.history = History::from_request_json(&document.to_string())?;
1007        self.document_thumbnail.replace(thumbnail.map(|image| (self.history.edit_serial(), image)));
1008        for notice in migration_notices {
1009            self.push_notice(notice);
1010        }
1011        Ok(self.rerun_history())
1012    }
1013
1014    /// The whole history request document (persistence / debugging), with the
1015    /// engine-owned extras folded back in on top of the kernel recipe so
1016    /// save→open round-trips them:
1017    ///
1018    /// * `metadata` — the Properties-panel store, written only when non-empty so
1019    ///   an un-annotated model persists as before;
1020    /// * `workbench` — the CURRENT active-workbench id
1021    ///   (`self.settings.workbench`), ALWAYS written so a saved part reopens in
1022    ///   the workbench it was saved from (restored by
1023    ///   [`Self::set_history_json`]). Always-embed keeps the invariant simple:
1024    ///   the serialized field tracks the LIVE setting, never a stale stored copy
1025    ///   — the load lifts it off the kernel recipe entirely, so this is the ONE
1026    ///   place it is (re)written. Note the deliberate consequence: switching
1027    ///   workbench changes this document, so the file panel's dirty flag flips —
1028    ///   consistent with the `metadata` precedent, and semantically true now
1029    ///   that the workbench is part of the saved file.
1030    pub fn history_request_json(&self) -> String {
1031        self.history_request_value().to_string()
1032    }
1033
1034    /// [`Self::history_request_json`] as a value, for a caller that edits the
1035    /// document (Update components): nothing is printed or parsed.
1036    pub fn history_request_value(&self) -> serde_json::Value {
1037        let mut document = self.history.request_value();
1038        if let Some(object) = document.as_object_mut() {
1039            if !self.metadata.is_empty() {
1040                object.insert("metadata".into(), self.metadata.to_json());
1041            }
1042            object.insert("thumbnail".into(), self.model_thumbnail());
1043            object.insert(
1044                "workbench".into(),
1045                serde_json::Value::String(self.settings.workbench.clone()),
1046            );
1047        }
1048        document
1049    }
1050
1051    /// Cache the small portable PNG by scene content, not by camera position.
1052    /// The picture is the DOCUMENT's, so it is rendered only while the scene is
1053    /// the document's model as built. A run the scene has not caught up with
1054    /// (an edit whose rebuild is still to come) gets an explicitly pending,
1055    /// empty image rather than a picture of the previous geometry; plugin work
1056    /// in flight is not that, since a candidate keeps its own scene until it
1057    /// commits. A transient presentation — an expression preview (a family
1058    /// member shown in the document's place) or an illustration — gets the
1059    /// document's own last picture (the one it was opened with, or the one its
1060    /// last save rendered) rather than what is on screen. A just-opened file
1061    /// keeps its embedded image while its unchanged recipe is being rebuilt.
1062    /// The document's picture is keyed by the history's edit serial, which
1063    /// moves on user edits, undo and redo only (a run's write-back ticks the
1064    /// revision). The same built model renders the same bytes every time.
1065    fn model_thumbnail(&self) -> serde_json::Value {
1066        let pending = self.run_generation != self.applied_generation();
1067        let transient = self.expression_preview.is_some() || self.illustration_snapshot.is_some();
1068        let mut key = vec![self.history.revision(), self.applied_generation(),
1069            self.scene.visibility_revision(), self.settings_generation, pending as u64, transient as u64];
1070        key.extend(self.scene.solids().iter().map(|s| s.revision));
1071        if let Some((old, image)) = self.thumbnail_cache.borrow().as_ref() {
1072            if *old == key { return image.clone(); }
1073        }
1074        let image = if pending || transient {
1075            self.document_thumbnail.borrow().as_ref().filter(|(serial, _)| *serial == self.history.edit_serial())
1076                .map(|(_, image)| image.clone())
1077                .unwrap_or_else(|| crate::thumbnail::empty_embedded("pending"))
1078        } else {
1079            let capture = crate::thumbnail::capture(&self.scene);
1080            let image = match crate::thumbnail::render_png(&capture, crate::thumbnail::SIZE) {
1081                Some(png) => crate::thumbnail::embedded_png(&png, crate::thumbnail::SIZE, "ready"),
1082                None => crate::thumbnail::empty_embedded("empty"),
1083            };
1084            self.document_thumbnail.replace(Some((self.history.edit_serial(), image.clone())));
1085            image
1086        };
1087        self.thumbnail_cache.replace(Some((key, image.clone())));
1088        image
1089    }
1090
1091    /// The tree listing `{ step, features:[{index,type,id}] }` for the UI panel.
1092    pub fn history_listing_json(&self) -> String {
1093        self.history.listing_json()
1094    }
1095
1096    /// The last build report JSON.
1097    pub fn history_report_json(&self) -> String {
1098        self.history_report.clone()
1099    }
1100
1101    pub fn history_len(&self) -> usize {
1102        self.history.len()
1103    }
1104
1105    /// The rolled-to (selected) feature index.
1106    pub fn history_rollback(&self) -> usize {
1107        self.history.rollback()
1108    }
1109
1110    /// A monotonic counter of mutation opportunities on the history document —
1111    /// the cache key for anything derived from it. See [`crate::history::History::revision`].
1112    pub fn history_revision(&self) -> u64 {
1113        self.history.revision()
1114    }
1115
1116    pub fn feature_type_at(&self, index: usize) -> Option<String> {
1117        self.history.feature_type(index)
1118    }
1119
1120    pub fn feature_id_at(&self, index: usize) -> Option<String> {
1121        self.history.feature_id(index)
1122    }
1123
1124    /// The `inputParams` document of feature `index` (`"null"` if none) — the
1125    /// dialog's editing-buffer source.
1126    pub fn feature_params_json(&self, index: usize) -> String {
1127        self.history
1128            .feature_params(index)
1129            .map(|v| v.to_string())
1130            .unwrap_or_else(|| "null".to_string())
1131    }
1132
1133    /// Mint the id for a NEW feature: `{base}{N}` where `base` is the feature's
1134    /// shortName ([`crate::features::feature_short_name`]) and `N` is the part
1135    /// history's persistent GLOBAL counter (monotonic, never reused, round-trips
1136    /// save/load — see [`History::next_feature_id`]). `&mut` because the counter
1137    /// advances; if the caller's `add_feature` then fails the number is simply
1138    /// skipped (monotonic-with-gaps is the contract, not an error).
1139    pub fn next_feature_id(&mut self, base: &str) -> String {
1140        self.history.next_feature_id(base)
1141    }
1142
1143    /// Adopt an edited document as one undo step and rebuild through the normal
1144    /// runner, preserving the tab's scene settings and history stack.
1145    ///
1146    /// Callers edit what [`Self::history_request_json`] gave them, which carries
1147    /// the session's `workbench` and `thumbnail`. Those are lifted off, as a load
1148    /// lifts them ([`Self::set_history_json`]): a copy kept inside the history
1149    /// goes stale at the next workbench switch or render and reads as an unsaved
1150    /// change of the document (and a stamped thumbnail made a freshly refreshed
1151    /// tab read dirty, which hid a reload that threw the refresh away).
1152    pub fn edit_document_json(&mut self, document: &str) -> Result<String, String> {
1153        let value: serde_json::Value = serde_json::from_str(document).map_err(|e| e.to_string())?;
1154        self.edit_document(value)
1155    }
1156
1157    /// [`Self::edit_document_json`] for a caller that holds the document as a
1158    /// value: nothing is printed or parsed again (at 500 parts the library's
1159    /// text round trips were most of an Update Components commit).
1160    pub fn edit_document(&mut self, mut value: serde_json::Value) -> Result<String, String> {
1161        use serde::Deserialize;
1162        let library = value.get("partsLibrary").map(brep_kernel::PartsLibraryMap::deserialize)
1163            .transpose().map_err(|e| format!("parts library: {e}"))?;
1164        if let Some(object) = value.as_object_mut() {
1165            object.remove("workbench");
1166            object.remove("thumbnail");
1167        }
1168        // Instructions taking a new state of their assembly re-target in the
1169        // same undo step (refused while one waits for approval).
1170        let retargeted = self.is_instructions() && self.instructions_fold_retarget(&mut value)?;
1171        self.history.adopt_document_value_checkpointed(value)?;
1172        if retargeted {
1173            self.instructions_views_pending = true;
1174        }
1175        // The runner's library channel reads the resident store. Updating only
1176        // History left it serving the old snapshot after Update Components.
1177        if let Some(library) = library {
1178            brep_kernel::install_parts_library(&library);
1179        }
1180        Ok(self.rerun_history())
1181    }
1182
1183    /// Publish derived changes folded into the current user edit, without adding
1184    /// another undo step (for example PCB placement and its assembly pose).
1185    pub fn rebuild_current_history(&mut self) -> String {
1186        self.rerun_history()
1187    }
1188
1189    /// Roll the model to feature `index`: re-run `features[0..=index]`.
1190    pub fn roll_to(&mut self, index: usize) -> String {
1191        self.history.set_rollback(index);
1192        self.rerun_history()
1193    }
1194
1195    /// Replace feature `id`'s input params and re-run at the current rollback →
1196    /// the viewport updates live.
1197    pub fn update_feature_params(
1198        &mut self,
1199        id: &str,
1200        input_params_json: &str,
1201    ) -> Result<String, String> {
1202        let mut params: serde_json::Value = serde_json::from_str(input_params_json)
1203            .map_err(|e| format!("feature params parse: {e}"))?;
1204        let index = self
1205            .history
1206            .index_of(id)
1207            .ok_or_else(|| format!("no feature with id '{id}'"))?;
1208        brep_kernel::feature_pipeline::validate_literal_feature_params(&self.feature_type_at(index).unwrap_or_default(), &params)?;
1209        self.stamp_face_transform_pivot(index, &mut params);
1210        self.history.set_feature_params(index, params);
1211        Ok(self.rerun_history())
1212    }
1213
1214    /// The pattern instance `name` (a copy solid, or a face/edge of one)
1215    /// belongs to: `(pattern feature id, instance number)`. For a part Pattern
1216    /// the numbers are the kernel's copy suffixes (`PAT1_3` is instance 3; the
1217    /// source is 1); for a Pattern Component, the instance component's own
1218    /// number (`APAT1.ACOMP3.i3:Part`, or the bare component id).
1219    pub fn pattern_instance_of(&self, name: &str) -> Option<(String, i64)> {
1220        let component = name.split(':').next().unwrap_or(name);
1221        if let Some((pattern, _seed, n)) = brep_kernel::pattern_component_instance(component) {
1222            return Some((pattern, n));
1223        }
1224        let (id, ty) = self.creating_feature(name)?;
1225        if ty != "PATTERN" {
1226            return None;
1227        }
1228        let tag = format!("{id}_");
1229        let number = name
1230            .strip_prefix(&tag)
1231            .or_else(|| name.rsplit_once(&format!("::{tag}")).map(|(_, rest)| rest))?;
1232        let digits: String = number.chars().take_while(char::is_ascii_digit).collect();
1233        digits.parse().ok().map(|n| (id, n))
1234    }
1235
1236    /// The Pattern feature `id`'s suppressed instance numbers.
1237    pub fn pattern_suppressed(&self, id: &str) -> Vec<i64> {
1238        let params = self.history.index_of(id).and_then(|index| self.history.feature_params(index));
1239        brep_kernel::pattern_parse_suppressed(params.as_ref().and_then(|p| p.get("suppressed")))
1240    }
1241
1242    /// Suppress (or unsuppress) instance `number` of the Pattern feature `id`:
1243    /// one parameter edit, one undo step. `None` for `number` with
1244    /// `suppressed == false` unsuppresses every instance.
1245    pub fn pattern_set_suppressed(&mut self, id: &str, number: Option<i64>, suppressed: bool) -> Result<(), String> {
1246        let index = self.history.index_of(id).ok_or_else(|| format!("no feature with id '{id}'"))?;
1247        if number == Some(1) && suppressed {
1248            return Err(format!(
1249                "instance 1 of {id} is its source (for a Pattern Component, the seed): hide or suppress the source instead"
1250            ));
1251        }
1252        let mut list = self.pattern_suppressed(id);
1253        match (number, suppressed) {
1254            (Some(n), true) if !list.contains(&n) => list.push(n),
1255            (Some(n), false) => list.retain(|&m| m != n),
1256            (None, false) => list.clear(),
1257            _ => return Ok(()),
1258        }
1259        list.sort_unstable();
1260        let mut params = self.history.feature_params(index).unwrap_or_else(|| serde_json::json!({}));
1261        let text = list.iter().map(i64::to_string).collect::<Vec<_>>().join(", ");
1262        params["suppressed"] = serde_json::Value::String(text);
1263        self.update_feature_params(id, &params.to_string()).map(|_| ())
1264    }
1265
1266    /// Replace the `inputParams` of MANY features as ONE model edit — the
1267    /// [`Self::update_feature_params`] batch sibling ([`Self::add_features`] is
1268    /// the append-only one). ONE undo checkpoint and ONE history re-run for the
1269    /// whole set.
1270    ///
1271    /// The lane that needs it: a PACKED BOM row rolls up every occurrence whose
1272    /// occurrence data matches, so editing one of its cells writes the same key
1273    /// into N ACOMP features. Looping `update_feature_params` would cost N
1274    /// re-runs and — worse — N undo entries, so taking back one visible edit
1275    /// would need N presses of undo.
1276    ///
1277    /// Unknown ids are reported (the whole batch is refused before anything is
1278    /// written, so a typo can never half-apply); an empty batch is a no-op that
1279    /// neither checkpoints nor runs.
1280    pub fn update_many_feature_params(
1281        &mut self,
1282        edits: &[(String, serde_json::Value)],
1283    ) -> Result<String, String> {
1284        if edits.is_empty() {
1285            return Ok(self.history_report.clone());
1286        }
1287        let mut resolved: Vec<(usize, serde_json::Value)> = Vec::with_capacity(edits.len());
1288        for (id, params) in edits {
1289            let index = self
1290                .history
1291                .index_of(id)
1292                .ok_or_else(|| format!("no feature with id '{id}'"))?;
1293            let mut params = params.clone();
1294            brep_kernel::feature_pipeline::validate_literal_feature_params(&self.feature_type_at(index).unwrap_or_default(), &params)?;
1295            self.stamp_face_transform_pivot(index, &mut params);
1296            resolved.push((index, params));
1297        }
1298        self.history.set_many_feature_params(&resolved);
1299        Ok(self.rerun_history())
1300    }
1301
1302    /// Append a feature (a full `{type, inputParams, …}` descriptor) and roll to
1303    /// it. The caller assigns a unique `id` (see [`Self::next_feature_id`]).
1304    pub fn add_feature(&mut self, feature_json: &str) -> Result<String, String> {
1305        let feature: serde_json::Value =
1306            serde_json::from_str(feature_json).map_err(|e| format!("feature parse: {e}"))?;
1307        brep_kernel::feature_pipeline::validate_literal_feature_params(feature["type"].as_str().unwrap_or_default(), &feature["inputParams"])?;
1308        self.refuse_duplicate_ids(std::slice::from_ref(&feature))?;
1309        if !self.append_plugin_features(std::slice::from_ref(&feature))? {
1310            self.history.push_feature(feature);
1311        }
1312        let last = self.history.len().saturating_sub(1);
1313        self.history.set_rollback(last);
1314        Ok(self.rerun_history())
1315    }
1316
1317    /// Append MANY features and roll to the last — [`Self::add_feature`]'s batch
1318    /// sibling, and the reason it exists: `add_feature` re-runs the WHOLE history
1319    /// per call, so a lane that appends N features by looping it costs N rebuilds
1320    /// (O(N²) work on an N-part STEP-assembly import). This pushes all of them,
1321    /// then re-runs ONCE — one rebuild, one undo checkpoint, one
1322    /// [`applied_generation`](Self::applied_generation) bump.
1323    ///
1324    /// Deliberately NOT [`Self::set_history_json`]: that is the document-SWITCH
1325    /// path (it clears the kernel history cache and resets the runner's delta
1326    /// baseline, forcing a full cold rebuild), which is the wrong mechanism for an
1327    /// append onto the live document.
1328    ///
1329    /// An empty batch is a no-op — no checkpoint, no run, no generation bump —
1330    /// and returns the standing report. The caller assigns each feature's unique
1331    /// `id` (see [`Self::next_feature_id`]).
1332    pub fn add_features(&mut self, features: &[serde_json::Value]) -> String {
1333        if features.is_empty() {
1334            return self.history_report.clone();
1335        }
1336        for feature in features {
1337            if let Err(error) = brep_kernel::feature_pipeline::validate_literal_feature_params(feature["type"].as_str().unwrap_or_default(), &feature["inputParams"]) {
1338                return serde_json::json!({"error":error}).to_string();
1339            }
1340        }
1341        if let Err(error) = self.refuse_duplicate_ids(features) {
1342            return serde_json::json!({"error":error}).to_string();
1343        }
1344        match self.append_plugin_features(features) {
1345            Ok(true) => {},
1346            Ok(false) => self.history.push_features(features.to_vec()),
1347            Err(error) => return serde_json::json!({"error":error}).to_string(),
1348        }
1349        let last = self.history.len().saturating_sub(1);
1350        self.history.set_rollback(last);
1351        self.rerun_history()
1352    }
1353
1354    /// A feature id names its solid, its faces and every reference to it, so it
1355    /// is unique in the history: an appended feature whose `inputParams.id` is
1356    /// already used — by the history or by an earlier feature of the same
1357    /// append — is refused, naming the id. (Two features sharing an id made the
1358    /// name resolve to one solid in the kernel and to the other in the display
1359    /// and the measurements, depending on which run produced it.)
1360    fn refuse_duplicate_ids(&self, features: &[serde_json::Value]) -> Result<(), String> {
1361        let mut seen: Vec<&str> = Vec::new();
1362        for feature in features {
1363            let Some(id) = feature.get("inputParams").and_then(|p| p.get("id")).and_then(serde_json::Value::as_str) else {
1364                continue;
1365            };
1366            if id.is_empty() {
1367                continue;
1368            }
1369            if let Some(index) = self.history.index_of(id) {
1370                return Err(format!("feature id `{id}` is already used by feature {index}; choose another id (or omit it to have one assigned)"));
1371            }
1372            if seen.contains(&id) {
1373                return Err(format!("feature id `{id}` is given twice in one append"));
1374            }
1375            seen.push(id);
1376        }
1377        Ok(())
1378    }
1379
1380    /// Delete the feature with id `id` (no-op if absent) and re-run, clamping the
1381    /// rolled-to step.
1382    pub fn delete_feature(&mut self, id: &str) -> String {
1383        match self.history.index_of(id) {
1384            Some(index) => self.delete_feature_at(index),
1385            None => self.rerun_history(),
1386        }
1387    }
1388
1389    /// Delete the feature at `index` (no-op when out of range) and re-run,
1390    /// clamping the rolled-to step.
1391    ///
1392    /// The POSITIONAL twin of [`Self::delete_feature`], and the only way to
1393    /// remove a feature whose `inputParams` carry no `id` — a shape a hand-built
1394    /// history JSON can still contain, and one an id-keyed delete can never
1395    /// address.
1396    pub fn delete_feature_at(&mut self, index: usize) -> String {
1397        if index < self.history.len() {
1398            self.history.remove_feature(index);
1399            let step = self
1400                .history
1401                .rollback()
1402                .min(self.history.len().saturating_sub(1));
1403            self.history.set_rollback(step);
1404        }
1405        self.rerun_history()
1406    }
1407
1408    /// Move feature `index` one slot up/down (reorder), keeping it selected.
1409    pub fn reorder_feature(&mut self, index: usize, up: bool) -> String {
1410        let len = self.history.len();
1411        if len >= 2 {
1412            let target = if up {
1413                index.checked_sub(1)
1414            } else if index + 1 < len {
1415                Some(index + 1)
1416            } else {
1417                None
1418            };
1419            if let Some(target) = target {
1420                self.history.swap(index, target);
1421                self.history.set_rollback(target);
1422            }
1423        }
1424        self.rerun_history()
1425    }
1426
1427}
1428
1429impl EngineState {
1430    /// Whether an undo step is available (to enable the toolbar's Undo button).
1431    pub fn can_undo(&self) -> bool {
1432        self.history.can_undo()
1433    }
1434
1435    /// Whether a redo step is available.
1436    pub fn can_redo(&self) -> bool {
1437        self.history.can_redo()
1438    }
1439
1440    /// Undo the last model mutation: restore the previous document + rolled-to
1441    /// step, then re-run + reconcile the scene. Returns the build report; a no-op
1442    /// (empty undo stack) returns the last report unchanged.
1443    pub fn undo(&mut self) -> String {
1444        if self.history.undo() {
1445            self.reinstall_rewound_parts_library();
1446            self.rerun_history()
1447        } else {
1448            self.history_report.clone()
1449        }
1450    }
1451
1452    /// Open an undo group on this document's history: every edit until
1453    /// [`Self::history_group_end`] is one undo step (a script's run).
1454    pub fn history_group_begin(&mut self) -> u64 {
1455        self.history.group_begin()
1456    }
1457
1458    /// Close undo group `token`; with `rollback`, restore the document as it
1459    /// was when the group opened and re-run. Whether it rolled back.
1460    pub fn history_group_end(&mut self, token: u64, rollback: bool) -> Result<bool, String> {
1461        let rolled = self.history.group_end(token, rollback)?;
1462        if rolled {
1463            self.reinstall_rewound_parts_library();
1464            self.rerun_history();
1465        }
1466        Ok(rolled)
1467    }
1468
1469    /// [`Self::undo`] without its run, for a caller that edits the document
1470    /// straight after (the Work part replacing a coalesced step): that edit's
1471    /// run is the one the document needs, and an undo run before it would be
1472    /// executed and thrown away. Whether there was a step to undo.
1473    pub fn undo_before_edit(&mut self) -> bool {
1474        let undone = self.history.undo();
1475        if undone {
1476            self.reinstall_rewound_parts_library();
1477        }
1478        undone
1479    }
1480
1481    /// Redo the last undone model mutation (symmetric with [`Self::undo`]).
1482    pub fn redo(&mut self) -> String {
1483        if self.history.redo() {
1484            self.reinstall_rewound_parts_library();
1485            self.rerun_history()
1486        } else {
1487            self.history_report.clone()
1488        }
1489    }
1490
1491    /// Push the just-rewound `partsLibrary` block back into the kernel's
1492    /// main-side store, so time travel moves the LIBRARY with the document.
1493    ///
1494    /// Without this, undo only half-works on anything that edits a library
1495    /// entry. The undo snapshot carries the block (`History::Snapshot`), but the
1496    /// block is a MIRROR: the per-run request does not carry it
1497    /// ([`History::prefix_request`] omits it) and a run that heals or GCs the
1498    /// store writes its result back over the block (`apply_run_output`). So a
1499    /// rewound block that was never pushed into the store is simply overwritten
1500    /// again, and the undo silently does nothing — which is what
1501    /// [`Self::set_part_attribute`](crate::engine_state::EngineState::set_part_attribute)
1502    /// would hit on its first undo.
1503    ///
1504    /// `install_parts_library` matches the store to the block BY CONTENT
1505    /// IDENTITY: an identical entry is kept verbatim (so a heal this side
1506    /// derived is not clobbered), a changed one is replaced and marked dirty
1507    /// (so the ACOMP self-heal re-derives every instance), and one absent from
1508    /// the block is dropped. An unchanged block is therefore a no-op, which is
1509    /// the overwhelmingly common undo.
1510    pub(super) fn reinstall_rewound_parts_library(&mut self) {
1511        let library = serde_json::from_value(self.history.parts_library().clone())
1512            .unwrap_or_default();
1513        brep_kernel::install_parts_library(&library);
1514    }
1515
1516    // --- Selection (Esc clears / viewport click selects) ------------------
1517
1518}
1519
1520/// Stamp per-loop ids onto every SKETCH feature's geometries in a history
1521/// document, in place.
1522///
1523/// The persistence half of per-loop face naming. The kernel DERIVES a loop's id
1524/// the same way every run, so this renames nothing; what it adds is durability —
1525/// a stored id survives deleting the edge it was originally derived from, which
1526/// derivation alone cannot. Running it on document LOAD (not only on sketch
1527/// commit) means a model built by a script, an import, or any other path that
1528/// never opens the sketch editor still gets its ids written down.
1529///
1530/// Assemblies: a parts-library entry embeds a FULL sub-part history
1531/// (`partsLibrary[*].document`), whose features can include sketches of its own,
1532/// so the walk recurses into each one. Without that, a sketch inside an imported
1533/// assembly part would derive correct names but carry no stored ids — exactly the
1534/// case (deleting the edge an id came from) that persisting exists to cover.
1535/// Depth is bounded: a sub-document's own library is seeded from the kernel store
1536/// rather than nested inside the entry, so one level of recursion reaches all of
1537/// them, and `MAX_LIBRARY_DEPTH` stops a malformed self-referential document.
1538///
1539/// Malformed features are skipped rather than rejected: this is a best-effort
1540/// enrichment on the way to the kernel, which validates the document itself.
1541fn stamp_sketch_loop_ids(document: &mut serde_json::Value) {
1542    /// Depth cap for the embedded sub-document walk — a guard against a
1543    /// hand-edited or corrupt document that nests libraries into each other.
1544    const MAX_LIBRARY_DEPTH: usize = 8;
1545    stamp_sketch_loop_ids_to_depth(document, MAX_LIBRARY_DEPTH);
1546}
1547
1548/// [`stamp_sketch_loop_ids`] with the remaining recursion budget.
1549fn stamp_sketch_loop_ids_to_depth(document: &mut serde_json::Value, depth: usize) {
1550    if let Some(features) = document
1551        .get_mut("features")
1552        .and_then(serde_json::Value::as_array_mut)
1553    {
1554        for feature in features {
1555            if feature.get("type").and_then(serde_json::Value::as_str) != Some("S") {
1556                continue;
1557            }
1558            let Some(sketch) = feature
1559                .get_mut("persistentData")
1560                .and_then(|data| data.get_mut("sketch"))
1561            else {
1562                continue;
1563            };
1564            brep_kernel::assign_sketch_loop_ids(sketch);
1565        }
1566    }
1567    if depth == 0 {
1568        return;
1569    }
1570    // Each library entry's embedded sub-part history gets the same treatment.
1571    let Some(library) = document
1572        .get_mut("partsLibrary")
1573        .and_then(serde_json::Value::as_object_mut)
1574    else {
1575        return;
1576    };
1577    for (_, entry) in library.iter_mut() {
1578        let Some(embedded) = entry.get_mut("document") else {
1579            continue;
1580        };
1581        stamp_sketch_loop_ids_to_depth(embedded, depth - 1);
1582    }
1583}
1584
1585impl EngineState {
1586    /// Load a whole model document (a saved `.nbrep` recipe) and FRAME it:
1587    /// [`set_history_json`](Self::set_history_json) (which rolls to the last
1588    /// feature) followed by [`zoom_to_fit`](Self::zoom_to_fit). The one call the
1589    /// file panel's **Open** needs — the model IS the engine-owned history, so
1590    /// opening a file is loading its request JSON and reframing. Returns the
1591    /// build-report JSON.
1592    pub fn load_model_and_fit(&mut self, request_json: &str) -> Result<String, String> {
1593        // Frame once the run lands, not now: under a background runner (native
1594        // thread / wasm worker) the freshly loaded model is not resident yet when
1595        // `set_history_json` returns, so an immediate `zoom_to_fit` would frame the
1596        // OLD scene. Set BEFORE the submit so the Inline runner's in-call `pump`
1597        // still frames synchronously. See [`EngineState::pending_fit`].
1598        self.pending_fit = true;
1599        let result = self.set_history_json(request_json);
1600        if result.is_err() {
1601            // A rejected document submits no run, so the armed fit would otherwise
1602            // fire on the OLD scene next frame — disarm it.
1603            self.pending_fit = false;
1604        }
1605        result
1606    }
1607}
1608
1609
1610
1611
1612
1613
1614/// The run report's `featureRefusals` object, added only when a feature failed
1615/// with a TYPED refusal: feature id → the kernel's refusal as it serializes
1616/// (`class` and its payload, `stage`, `message` — the text `featureErrors`
1617/// carries for the same feature) plus `step`, the motion step that refused,
1618/// when the feature records one (Transform Face: `rotation` / `translation`).
1619/// Absent otherwise, so a report with no typed refusal is byte-for-byte what it
1620/// was before this key existed.
1621fn with_refusals(
1622    mut value: serde_json::Value,
1623    report: &crate::pipeline::SceneBuildReport,
1624) -> serde_json::Value {
1625    if report.feature_refusals.is_empty() {
1626        return value;
1627    }
1628    let refusals: serde_json::Map<String, serde_json::Value> = report
1629        .feature_refusals
1630        .iter()
1631        .map(|(id, refusal, step)| {
1632            let mut value = serde_json::to_value(refusal).unwrap_or(serde_json::Value::Null);
1633            if let (Some(step), serde_json::Value::Object(object)) = (step, &mut value) {
1634                object.insert("step".into(), serde_json::Value::String(step.clone()));
1635            }
1636            (id.clone(), value)
1637        })
1638        .collect();
1639    if let serde_json::Value::Object(object) = &mut value {
1640        object.insert("featureRefusals".into(), serde_json::Value::Object(refusals));
1641    }
1642    value
1643}
1644
1645/// The run report's `featureApproximations` object, added only when a feature
1646/// SUCCEEDED carrying a MEASURED approximation: feature id → the kernel's
1647/// approximations as they serialize (`code`, `body`, `measured`, `bar`,
1648/// `volume_bound`, `edges[{edge_id, curve_ref, face_ref, surface_ref,
1649/// off_carrier_mm, origin}]`, `message`), each plus a one-line `summary` the
1650/// history tree shows as the feature's leaf. Absent otherwise, so a report with
1651/// no approximation is byte-for-byte what it was before this key existed — the
1652/// same contract as `featureRefusals`. Distinct from `featureRefusals` (the
1653/// result does not stand) and `featureFulfilment` (what was done of what was
1654/// asked): an approximation's result stands, and this says by how much it is
1655/// not exact.
1656fn with_approximations(
1657    mut value: serde_json::Value,
1658    report: &crate::pipeline::SceneBuildReport,
1659) -> serde_json::Value {
1660    if report.feature_approximations.is_empty() {
1661        return value;
1662    }
1663    let approximations: serde_json::Map<String, serde_json::Value> = report
1664        .feature_approximations
1665        .iter()
1666        .map(|(id, approximations)| {
1667            let entries: Vec<serde_json::Value> = approximations
1668                .iter()
1669                .map(|approximation| {
1670                    let mut value =
1671                        serde_json::to_value(approximation).unwrap_or(serde_json::Value::Null);
1672                    if let serde_json::Value::Object(object) = &mut value {
1673                        object.insert(
1674                            "summary".into(),
1675                            serde_json::Value::String(approximation_summary(approximation)),
1676                        );
1677                    }
1678                    value
1679                })
1680                .collect();
1681            (id.clone(), serde_json::Value::Array(entries))
1682        })
1683        .collect();
1684    if let serde_json::Value::Object(object) = &mut value {
1685        object.insert("featureApproximations".into(), serde_json::Value::Object(approximations));
1686    }
1687    value
1688}
1689
1690/// The run report's `featureAutoTargets` object, added only when a feature's
1691/// boolean named no target and ran into the part's ONLY solid: feature id → that
1692/// solid's name. Absent otherwise, so a report with no implicit target is
1693/// byte-for-byte what it was before this key existed (the `featureRefusals`
1694/// contract). The feature panel labels the boolean's target field with it.
1695fn with_auto_targets(
1696    mut value: serde_json::Value,
1697    report: &crate::pipeline::SceneBuildReport,
1698) -> serde_json::Value {
1699    if report.feature_auto_targets.is_empty() {
1700        return value;
1701    }
1702    let targets: serde_json::Map<String, serde_json::Value> = report
1703        .feature_auto_targets
1704        .iter()
1705        .map(|(id, name)| (id.clone(), serde_json::Value::String(name.clone())))
1706        .collect();
1707    if let serde_json::Value::Object(object) = &mut value {
1708        object.insert("featureAutoTargets".into(), serde_json::Value::Object(targets));
1709    }
1710    value
1711}
1712
1713/// One line for an approximation, for the history tree's leaf: the marker
1714/// word, the code, the body, the measurement against its bar and the volume
1715/// bound when the code implies one. Code-agnostic on purpose — the units of
1716/// `measured` are the code's (mm² for `import.shell_closure`), so only the
1717/// volume bound, which every code states in mm³, carries a unit here; the
1718/// kernel's `message` has the full text with units, faces and edges.
1719pub fn approximation_summary(approximation: &brep_kernel::Approximation) -> String {
1720    let ratio = if approximation.bar > 0.0 {
1721        format!(" ({:.1}x)", approximation.measured / approximation.bar)
1722    } else {
1723        String::new()
1724    };
1725    let bound = approximation
1726        .volume_bound
1727        .map(|bound| format!("; volume determined to ±{bound:.3e} mm³"))
1728        .unwrap_or_default();
1729    format!(
1730        "approximate ({}): {} measured {:.3e} against a bar of {:.3e}{ratio}{bound}",
1731        approximation.code, approximation.body, approximation.measured, approximation.bar
1732    )
1733}
1734
1735/// The run report's `featureFulfilment` object: feature id → the typed
1736/// fulfilment (`requested`, `applied`, `rejected[{name, kind, reason}]`) plus a
1737/// one-line `summary` the tree and the automation report show verbatim. Only the
1738/// features that reported one appear, so an absent key means "did everything".
1739fn fulfilment_map(report: &crate::pipeline::SceneBuildReport) -> serde_json::Map<String, serde_json::Value> {
1740    report
1741        .feature_fulfilment
1742        .iter()
1743        .map(|(id, fulfilment)| {
1744            let mut value = serde_json::to_value(fulfilment).unwrap_or(serde_json::Value::Null);
1745            if let serde_json::Value::Object(object) = &mut value {
1746                object.insert("summary".into(), serde_json::Value::String(fulfilment.summary()));
1747            }
1748            (id.clone(), value)
1749        })
1750        .collect()
1751}
1752
1753impl EngineState {
1754    /// Adopt one solver pose write-back without an undo entry: a component's
1755    /// pose into its ACOMP `transform` (unless expressions drive it); a
1756    /// flexible sub-assembly occurrence's (`ACOMP1:ACOMP2`, part-local) into
1757    /// its instance's `flexiblePoses`, never the child document.
1758    pub(crate) fn fold_assembly_fixed(&mut self, id: &str, fixed: bool) {
1759        let Some((owner, path)) = id.split_once(':') else {
1760            self.history.fold_param_no_undo(id, "isFixed", serde_json::Value::Bool(fixed));
1761            return;
1762        };
1763        let Some(index) = self.history.index_of(owner) else { return };
1764        let mut overrides = self
1765            .history
1766            .feature_params(index)
1767            .and_then(|params| params.get("flexibleFixed").cloned())
1768            .filter(serde_json::Value::is_object)
1769            .unwrap_or_else(|| serde_json::json!({}));
1770        overrides[path] = serde_json::Value::Bool(fixed);
1771        self.history.fold_param_no_undo(owner, "flexibleFixed", overrides);
1772    }
1773
1774    pub(crate) fn fold_assembly_pose(&mut self, id: &str, pose: serde_json::Value) {
1775        if let Some((owner, path)) = id.split_once(':') {
1776            let Some(index) = self.history.index_of(owner) else { return };
1777            let mut poses = self
1778                .history
1779                .feature_params(index)
1780                .and_then(|params| params.get("flexiblePoses").cloned())
1781                .filter(serde_json::Value::is_object)
1782                .unwrap_or_else(|| serde_json::json!({}));
1783            poses[path] = pose;
1784            self.history.fold_param_no_undo(owner, "flexiblePoses", poses);
1785            return;
1786        }
1787        let expression_driven = self.history.index_of(id).and_then(|i| self.history.feature_params(i))
1788            .is_some_and(|p| super::batch::contains_expression(&p["transform"]));
1789        if !expression_driven { self.history.fold_param_no_undo(id, "transform", pose); }
1790    }
1791}
1792