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_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        )
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`](Self::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_plugin_installations();
115        // The runner REFUSED a run because its resident parts library could not
116        // serve it (see `Reply::NeedPartsLibrary`). It has already forgotten
117        // its copy, so re-running re-installs the library and re-submits. This
118        // is a real path, not just a tripwire: the kernel's orphan GC drops
119        // entries at the end of every run, so an undo to zero components empties
120        // the RUNNER's store while this side's (which never ran) keeps
121        // everything — no revision bookkeeping can see that, only the runner's
122        // content preflight can. `library_resync` breaks the rerun→pump→rerun
123        // recursion (the reinstall makes the second attempt succeed, but a
124        // guard beats relying on that). The guard is tested FIRST because
125        // `poll_library_request` CONSUMES the flag — polling it while a resync
126        // is already in flight would swallow a second refusal.
127        if !self.library_resync && self.runner.poll_library_request() {
128            self.library_resync = true;
129            self.rerun_history();
130            self.library_resync = false;
131        }
132        // A symbol edit relabelled, added or removed a PORT feature. The eCAD
133        // editors write their blocks straight into the history, which does not
134        // run it, so the run happens here, on the next frame, for every host.
135        if self.history.take_ports_followed() {
136            self.rerun_history();
137        }
138        while let Some(reply) = self.runner.poll_mesh_import() {
139            // A document switch clears this set. Ignore any older reconstruction
140            // reply that was already running when its Reset crossed the queue.
141            let Some(destination) = self.pending_mesh_imports.remove(&reply.id) else {
142                continue;
143            };
144            if destination == MeshImportDestination::Preview {
145                self.mesh_preview_results.push_back(reply);
146                continue;
147            }
148            match reply.result {
149                Ok(output) => match self.import_step_feature(&output.step_text) {
150                    Ok(_) => {
151                        self.push_notice(
152                            "RANSAC reconstruction complete; building imported CAD body",
153                        );
154                        // A body the chain refused is left out of the model;
155                        // say so, by name, rather than let it vanish.
156                        if let Some(notice) = crate::runner::refused_bodies_notice(&output.report)
157                        {
158                            self.push_notice(notice);
159                        }
160                    }
161                    Err(error) => self.push_notice(format!("mesh import failed: {error}")),
162                },
163                Err(error) => self.push_notice(format!("mesh import failed: {error}")),
164            }
165        }
166        // STEP probes: the parse ran on the runner; stash the structure it
167        // found (the import consumes it) and queue the outcome for the panel.
168        while let Some(reply) = self.runner.poll_step_probe() {
169            if !self.pending_step_probes.remove(&reply.id) {
170                continue; // cancelled, or a document switch — nobody is waiting
171            }
172            let outcome = match reply.result {
173                Ok(Some(assembly)) => {
174                    let probe = super::model_io::probe_counts(&assembly);
175                    // The kernel already refuses a structure that reaches no
176                    // geometry, so this is belt-and-braces: an assembly with
177                    // zero instances would import as zero components, which
178                    // is the silent failure the structured lane forbids.
179                    if probe.instances == 0 {
180                        super::StepProbeOutcome::Flat
181                    } else {
182                        self.pending_step_assembly = Some(assembly);
183                        super::StepProbeOutcome::Structure(probe)
184                    }
185                }
186                Ok(None) => super::StepProbeOutcome::Flat,
187                Err(error) => super::StepProbeOutcome::Failed(error),
188            };
189            self.step_probe_results.push_back((reply.id, outcome));
190        }
191        // The in-flight run's latest progress report (a background runner
192        // posts one before each feature it executes). Kept only for a run
193        // newer than the applied one: a report from a superseded run — or one
194        // still arriving after a cancel bumped the generations — is stale.
195        if let Some(progress) = self.runner.poll_progress() {
196            if progress.generation > self.applied_generation {
197                self.run_progress = Some(progress);
198            }
199        }
200        self.pump_plugin_callbacks();
201        while let Some(reply) = self.runner.poll_run() {
202            self.runs_replied += 1;
203            if reply.generation >= self.run_generation && self.accept_plugin_run(&reply) {
204                self.applied_generation = reply.generation;
205                self.apply_run_output(reply.output);
206            } else {
207                // Superseded — but the runner's baseline has moved past every
208                // display in it, so a later keep can name one of them. Hold
209                // them for that keep (see `superseded_displays`).
210                for (name, _handle, maybe) in reply.output.snapshot {
211                    if let Some(display) = maybe {
212                        self.superseded_displays.insert(name, display);
213                    }
214                }
215            }
216        }
217        if !self.run_pending() {
218            self.run_progress = None;
219        }
220        // Deferred one-shot framing (Import / Open): frame the scene the moment the
221        // run they submitted has fully landed. Consumed unconditionally once the run
222        // is no longer pending — even when it produced no solids (bbox empty →
223        // `zoom_to_fit` no-ops) — so a later unrelated run never inherits a stale fit.
224        if self.pending_fit && !self.run_pending() {
225            self.pending_fit = false;
226            self.zoom_to_fit();
227        }
228        // Drain any completed measurement replies too (a background runner surfaces
229        // them a frame after selection); for Inline this is a no-op each frame since
230        // `object_info_json` already pumped its own query same-call.
231        self.pump_queries();
232        self.pump_topology();
233        self.pump_sheet_lines();
234    }
235
236    /// Ask the runner for the B-rep topology of the displayed solids `names`
237    /// that the scene does not hold for their current handle and that have not
238    /// been asked for since the last applied run, then drain whatever has
239    /// already answered (all of it, for the synchronous Inline runner). The
240    /// drawing sheet's door, and the balloon bubble drag's (which re-projects
241    /// the arrow head on the exact solid, `pmi_reproject_balloon_head`):
242    /// nothing else needs topology on this side, so a document with neither a
243    /// sheet nor a dragged balloon never asks.
244    pub fn request_exact_solids<'a>(&mut self, names: impl IntoIterator<Item = &'a str>) {
245        let mut wanted: Vec<(String, u32)> = Vec::new();
246        for name in names {
247            let Some(display) = self.scene.solid(name) else { continue };
248            let handle = display.source_handle;
249            if handle == 0 || display.is_sketch || self.scene.exact_solid(name).is_some() {
250                continue;
251            }
252            if self.topology_asked.insert(handle) {
253                wanted.push((name.to_string(), handle));
254            }
255        }
256        if !wanted.is_empty() {
257            self.next_topology_id += 1;
258            let id = self.next_topology_id;
259            self.pending_topology.insert(id);
260            self.runner.submit_topology(crate::runner::TopologyRequest { id, solids: wanted });
261        }
262        self.pump_topology();
263    }
264
265    /// Fold every answered topology request into the scene. A reply for a
266    /// display that has moved on to another handle is dropped; the next
267    /// projection asks for the new one.
268    pub fn pump_topology(&mut self) {
269        let mut landed = false;
270        while let Some(reply) = self.runner.poll_topology() {
271            if !self.pending_topology.remove(&reply.id) {
272                continue; // cancelled, or a document switch
273            }
274            for (name, handle, solid) in reply.solids {
275                self.scene.set_exact_solid(&name, handle, solid);
276            }
277            landed = true;
278        }
279        if landed {
280            self.pmi_refresh_stale_balloon();
281        }
282    }
283
284    /// Whether a topology request is still in flight — the frame loop and the
285    /// idle contract wait on it like a measurement query.
286    pub fn topology_pending(&self) -> bool {
287        !self.pending_topology.is_empty()
288    }
289
290    /// Whether a measurement query is still in flight (its reply not yet drained) —
291    /// the query analogue of [`run_pending`](Self::run_pending), so the app keeps the
292    /// frame loop alive until a background runner's measurement lands and displays.
293    /// Always `false` for the synchronous Inline runner.
294    pub fn queries_pending(&self) -> bool {
295        !self.pending_query.is_empty()
296    }
297
298    /// Whether RANSAC reconstruction is still executing on the native runner
299    /// thread or browser worker.
300    pub fn mesh_imports_pending(&self) -> bool {
301        !self.pending_mesh_imports.is_empty()
302    }
303
304    /// Whether a submitted run has not yet been applied (`run_generation !=
305    /// applied_generation`). Always `false` for the synchronous Inline runner
306    /// (submit → immediate `pump` keeps the two in lockstep); a background runner
307    /// uses it to keep the frame loop alive until its reply lands.
308    pub fn run_pending(&self) -> bool {
309        self.run_generation != self.applied_generation || self.plugin_work_pending()
310    }
311
312    /// The generation of the last APPLIED run — bumps once per applied history
313    /// run (document loads, edits, constraint mutations, solves). A cheap
314    /// staleness key for app-side caches derived from the applied document
315    /// (the update-components outdated badge keys on it).
316    pub fn applied_generation(&self) -> u64 {
317        self.applied_generation
318    }
319
320    /// What the in-flight run is executing right now, as far as the runner
321    /// has reported (see [`crate::runner::RunProgress`]); `None` when nothing
322    /// is running or the run has not reached its first executed feature.
323    pub fn run_progress(&self) -> Option<&crate::runner::RunProgress> {
324        self.run_progress.as_ref()
325    }
326
327    /// The feature id the last cancelled run was executing (empty when it was
328    /// cancelled before any progress arrived), until the next submit.
329    pub fn cancelled_run(&self) -> Option<&str> {
330        self.cancelled_run.as_deref()
331    }
332
333    /// CANCEL the in-flight run. The runner abandons its work and comes back
334    /// with an EMPTY resident registry (a fresh thread / a fresh worker — see
335    /// [`crate::runner::HistoryRunner::cancel`]), so this side forgets
336    /// everything that was waiting on it: the run itself (generations are
337    /// bumped past it, so a straggling reply from the old runner is dropped
338    /// as stale), pending measurement queries, document-bound mesh imports
339    /// and a deferred fit. The DISPLAY SCENE is left as the last applied run
340    /// built it — the document is ahead of it now, which the notice says; the
341    /// next edit re-runs the whole history through the new runner (a cold
342    /// run: the warm cache went with the old one). Nothing inside a feature
343    /// is interruptible, so the native thread keeps burning CPU until the
344    /// feature it is on finishes; the browser worker is terminated outright.
345    ///
346    /// `false` when nothing was running, or the runner cannot abandon (the
347    /// synchronous Inline runner, whose runs are over before anyone can ask).
348    pub fn cancel_run(&mut self) -> bool {
349        self.cancel_run_with_notice(true)
350    }
351
352    pub(super) fn cancel_run_with_notice(&mut self, notify: bool) -> bool {
353        if self.plugin_installation_pending() { return false; }
354        if self.cancel_plugin_action() { return true; }
355        if !self.run_pending() || !self.runner.cancel() {
356            return false;
357        }
358        let stalled_on = self.run_progress.take().map(|progress| progress.feature_id);
359        // Past every generation submitted so far: a reply the old runner
360        // already posted (sitting in the main event loop on wasm) carries an
361        // older number than this and is dropped by `pump`'s gate.
362        self.run_generation += 1;
363        self.applied_generation = self.run_generation;
364        self.pending_query.clear();
365        // The new runner's registry counts handles from one again, so every
366        // topology keyed by an old handle could alias a new display.
367        self.pending_topology.clear();
368        self.topology_asked.clear();
369        self.scene.clear_exact_solids();
370        self.sheet_lines.forget();
371        // The old runner's held displays name ITS handles; the new one's
372        // first run emits every display afresh.
373        self.superseded_displays.clear();
374        let dropped_imports = self.pending_mesh_imports.len();
375        self.pending_mesh_imports.clear();
376        let dropped_probes = self.pending_step_probes.len();
377        self.pending_step_probes.clear();
378        self.pending_fit = false;
379        self.library_resync = false;
380        self.cancelled_run = Some(stalled_on.clone().unwrap_or_default());
381        if notify {
382            self.push_notice(match stalled_on {
383                Some(id) if !id.is_empty() => format!(
384                    "Run cancelled while executing {id}. The model shows the last completed \
385                     result; edit or delete the feature to rebuild."
386                ),
387                _ => "Run cancelled. The model shows the last completed result; the next edit \
388                      rebuilds it."
389                    .to_string(),
390            });
391        }
392        if dropped_imports > 0 {
393            self.push_notice(format!(
394                "cancelled {dropped_imports} pending mesh import{}",
395                if dropped_imports == 1 { "" } else { "s" }
396            ));
397        }
398        if dropped_probes > 0 {
399            self.push_notice("cancelled the STEP file being read — upload it again to import it");
400        }
401        true
402    }
403
404    /// How many run replies the runner has sent back (see `runs_replied`).
405    pub fn runs_replied(&self) -> u64 {
406        self.runs_replied
407    }
408
409    /// Whether the display scene currently holds at least one solid. Used by the
410    /// app's async-safe first-frame framing: under a background runner (thread /
411    /// worker) the seed run lands a frame (or many) after boot, so the shell waits
412    /// for `has_solids() && !run_pending()` before its one-shot `zoom_to_fit`.
413    pub fn has_solids(&self) -> bool {
414        !self.scene.solids().is_empty()
415    }
416
417    /// Swap in a different history runner (the platform injects its own — the native
418    /// app installs a [`ThreadRunner`](crate::runner::ThreadRunner); wasm keeps the
419    /// default Inline until M3's worker). Resets the new runner's delta baseline so
420    /// the next run rebuilds fully. Call BEFORE seeding a document so the seed builds
421    /// through the installed runner.
422    pub fn set_runner(&mut self, runner: Box<dyn crate::runner::HistoryRunner>) {
423        self.runner = runner;
424        self.plugin_runner_replaced();
425        self.runner.reset();
426        self.superseded_displays.clear();
427        self.pending_mesh_imports.clear();
428        self.mesh_preview_results.clear();
429        self.pending_topology.clear();
430        self.topology_asked.clear();
431        self.scene.clear_exact_solids();
432        self.sheet_lines.forget();
433    }
434
435    /// Apply a [`SceneRunner`](crate::pipeline::SceneRunner) delta to the display
436    /// scene and build the history report JSON — the APPLY half of the M2a seam.
437    ///
438    /// Reconcile preserving ORDER + reuse: MOVE every current display out of the
439    /// scene ([`RenderScene::drain`](crate::scene::RenderScene::drain)) into a
440    /// name-keyed `kept` map, then reinsert in snapshot order — a fresh entry
441    /// (`Some`) replaces, an UNCHANGED entry (`None`) reuses its moved-out display
442    /// (stable `revision` ⇒ GPU-buffer reuse, Task-1; its `source_handle` equals
443    /// the run's handle by the monotonic-handle reuse invariant). Leftovers in
444    /// `kept` — departed kernel solids AND the previous run's sketch sheets — are
445    /// dropped; `refresh_committed_sketches` (run in the shared continuation after)
446    /// re-adds the sheets, so dropping them here is correct.
447    ///
448    /// Then the report continuation (identical to the pre-seam run): keep the run's
449    /// resolved frames + solved sketch profiles and fold the per-feature timings /
450    /// output-names into the id-keyed report JSON, then hand it to
451    /// [`finish_apply`](Self::finish_apply) — the shared dirty/gizmo/overlay tail
452    /// that the parse-error branch in [`rerun_history`](Self::rerun_history) also
453    /// calls, so both paths share the continuation verbatim.
454    pub(super) fn apply_run_output(&mut self, output: crate::pipeline::RunOutput) {
455        let crate::pipeline::RunOutput {
456            snapshot,
457            mut report,
458            provenance,
459            entity_origin,
460            assembly_poses,
461            assembly_fixed,
462            moved_solids,
463            imported_colors,
464            consumed,
465            assembly,
466        } = output;
467        for (id, data) in &report.plugin_persistent_data {
468            self.history.fold_plugin_persistent_data(id, data.clone());
469        }
470        // The runner already forced fresh displays for the solver-moved solids
471        // (their snapshot entries arrive `Some`); nothing extra to do main-side.
472        let _ = moved_solids;
473        // Un-pose the active PMI view's exploded displays BEFORE the reconcile
474        // keeps them, so the re-pose after apply starts from the modeling
475        // pose and never compounds.
476        self.pmi_restore_explode();
477
478        // The names this run consumed, for the committed-sketch refresh in the
479        // tail below — the runner folded them, so the UI thread does not execute
480        // the history a second time to fold them again.
481        self.consumed_names = consumed.into_iter().collect();
482        // Adopt the run's assembly tail. The PARTS LIBRARY comes out of it here
483        // rather than in `adopt_assembly_sync`, so the (potentially megabyte)
484        // store is installed and mirrored into the document exactly once, on the
485        // run that moved it, and never kept in the snapshot afterwards.
486        self.assembly_sync = assembly.map(|mut sync| {
487            if let Some(library) = sync.parts_library.take() {
488                // CLEAR, then install. `install_parts_library` keeps a resident
489                // entry whose identity (source key + signature + document hash)
490                // matches the incoming one VERBATIM — the right rule for the
491                // main → runner direction it was written for, because the
492                // runner may hold a heal the sender never saw. This is the
493                // other direction: the run that just healed an entry (a heal
494                // rewrites the snapshot and deliberately does NOT change the
495                // identity) is the one holding the newer copy, so keeping the
496                // resident one would silently drop it and the document would
497                // re-heal on every open. Emptying first makes every incoming
498                // entry a fresh insert, i.e. "the store IS what the run left".
499                brep_kernel::install_parts_library(&Default::default());
500                brep_kernel::install_parts_library(&library);
501                if let Ok(block) = serde_json::to_value(&library) {
502                    self.history.set_parts_library(block);
503                }
504            }
505            sync
506        });
507
508        // --- assembly pose-authority fold -------- Adopt the solver's pose /
509        // isFixed write-backs into the owning ACOMP features by
510        // `inputParams.id` BEFORE anything persists or re-runs this document.
511        // Deliberately NOT `update_feature_params`: the fold must not mint an
512        // undo entry nor trigger a rerun (rerun → solve → fold → rerun would
513        // loop); `fold_param_no_undo` writes the param silently, and the next
514        // run's request simply carries the solved pose (a no-motion solve emits
515        // no updates, so fingerprints never churn).
516        for (id, pose) in assembly_poses {
517            let expression_driven = self.history.index_of(&id).and_then(|i| self.history.feature_params(i))
518                .is_some_and(|p| super::batch::contains_expression(&p["transform"]));
519            if !expression_driven { self.history.fold_param_no_undo(&id, "transform", pose); }
520        }
521        for (id, fixed) in assembly_fixed {
522            self.history
523                .fold_param_no_undo(&id, "isFixed", serde_json::Value::Bool(fixed));
524        }
525
526        // Adopt the run's eager provenance (SOLID last-writer) + entity origin
527        // (face/edge first-writer) wholesale (both drive `creating_feature` + the
528        // Info tab's `creatingFeature` with no cold re-run), and INVALIDATE the
529        // object-info measurement cache + any in-flight query: the geometry changed,
530        // so cached measurements are stale and a pending reply is superseded (a
531        // re-selection re-queries against the fresh geometry).
532        self.provenance = provenance.into_iter().collect();
533        self.entity_origin = entity_origin.into_iter().collect();
534        self.info_cache.clear();
535        self.pending_query.clear();
536
537        // Fold the run's IMPORTED COLOURS into the engine's own metadata store —
538        // the seam between the kernel's (thread-local, never persisted by us)
539        // name-keyed store and the one the Info window edits and the document
540        // saves. NON-overwriting on purpose: an import re-stamps its colour on
541        // every replay, and a colour the user changed in the panel must win.
542        for (name, hex) in imported_colors {
543            if self.metadata.attribute(&name, "color").is_none() {
544                self.metadata.set_attribute(&name, "color", &hex);
545            }
546        }
547
548        // Reconcile the scene: move current displays out, reinsert in order.
549        let mut kept: std::collections::HashMap<String, crate::scene::SolidDisplay> = self
550            .scene
551            .drain()
552            .into_iter()
553            .map(|solid| (solid.name.clone(), solid))
554            .collect();
555        for (name, handle, maybe) in snapshot {
556            match maybe {
557                Some(display) => {
558                    self.superseded_displays.remove(&name);
559                    self.scene.insert_solid(display);
560                }
561                // "Unchanged": the runner last emitted `handle` for this name.
562                // That emit is the scene's display when its reply was applied,
563                // and a held superseded display when it was dropped — the
564                // handle says which, since handles are monotonic and never
565                // recycled within one runner. Neither holding it is a protocol
566                // breach (a reply lost between the runner and `pump`), and the
567                // app is not the place to crash on one, in any build: the
568                // solid is left out of this frame and the report names it.
569                None => {
570                    let kept_display = kept.remove(&name);
571                    let display = match kept_display {
572                        Some(display) if display.source_handle == handle => Some(display),
573                        _ => self
574                            .superseded_displays
575                            .remove(&name)
576                            .filter(|display| display.source_handle == handle),
577                    };
578                    match display {
579                        Some(display) => self.scene.insert_solid(display),
580                        None => {
581                            report.display_errors.push(format!(
582                                "{name}: the run kept resident handle {handle}, which no reply has displayed"
583                            ));
584                        }
585                    }
586                }
587            }
588        }
589        // Topology is kept only for a display whose handle survived the run,
590        // and so is the record of what was asked: a surviving handle is in
591        // flight, answered or not in the registry, and every other is gone.
592        self.scene.prune_exact_solids();
593        let live: std::collections::HashSet<u32> =
594            self.scene.solids().iter().map(|solid| solid.source_handle).collect();
595        self.topology_asked.retain(|handle| live.contains(handle));
596
597        // Keep every plane frame the run resolved (DATUM/PLANE/SKETCH);
598        // `refresh_construction_datums` filters to the D/P producers.
599        self.construction_frames = report.frames.clone();
600        // Keep every solved sketch profile so `refresh_committed_sketches` can
601        // synthesize the committed sketch sheet solids.
602        self.sketch_profiles = report.profiles.clone();
603        // ...and every path chain, so a sketch whose geometry closes NO region (an
604        // open chain — the reported single line) still has something to draw.
605        self.sketch_paths = report.paths.clone();
606        // ...and every published point, so a sketch holding ONLY points (a
607        // hole-placement sketch) still has something to draw.
608        self.sketch_points = report.points.clone();
609        // Keep every axis line the run published so the angle gizmo can resolve a
610        // revolve `axis` reference to a world line without re-running.
611        self.sketch_axes = report.axes.clone();
612        // ...and the wire-harness routing report, for the harness panel.
613        self.wire_harness_report = report.wire_harness.clone();
614        // ...and the ports tail's report, for the Qualify panel: the tail is not
615        // a feature, so nothing else carries what it refused or could not resolve.
616        self.ports_report = report.ports.clone();
617        // ...and the PMI report (every view's resolved annotations).
618        self.pmi_report = report.pmi.clone();
619        self.fold_plugin_annotation_data();
620        // Fold the per-feature timing / output-name pairs into id-keyed maps so the
621        // history-tree UI can look them up by feature id.
622        let timings: serde_json::Map<String, serde_json::Value> = report
623            .feature_timings
624            .iter()
625            .map(|(id, ms)| (id.clone(), serde_json::json!(ms)))
626            .collect();
627        let outputs: serde_json::Map<String, serde_json::Value> = report
628            .feature_outputs
629            .iter()
630            .map(|(id, names)| (id.clone(), serde_json::json!(names)))
631            .collect();
632        let report_json = with_approximations(
633            with_refusals(
634                serde_json::json!({
635                    "featureErrors": report.feature_errors,
636                    "featureNotes": report.feature_notes,
637                    "featureFulfilment": fulfilment_map(&report),
638                    "unresolved": report.unresolved,
639                    "displayErrors": report.display_errors,
640                    "featureTimings": timings,
641                    "featureOutputs": outputs,
642                }),
643                &report,
644            ),
645            &report,
646        )
647        .to_string();
648        self.finish_apply(report_json);
649    }
650
651    /// The shared post-apply TAIL: mark dirty, store the report JSON, re-sync an
652    /// armed gizmo, and rebuild the persistent committed-sketch + construction-datum
653    /// overlays. Called after a real run's [`apply_run_output`](Self::apply_run_output)
654    /// AND from [`rerun_history`](Self::rerun_history)'s parse-error branch, so both
655    /// paths run the identical continuation. Callers read the result via
656    /// [`Self::history_report`](Self::history_report_json).
657    fn finish_apply(&mut self, report_json: String) {
658        self.dirty = true;
659        self.history_report = report_json;
660        // Keep an armed gizmo glued to its feature as the model rebuilds. During a
661        // transform drag the re-sync is driven by `transform_drag_to` itself (which
662        // resolves the delta against the frozen grab frame first, then syncs), so
663        // skip it here to avoid a redundant double-feed per drag frame. Transform
664        // mode re-feeds the widget frame; dimension mode re-projects the annotation
665        // leaders onto the rebuilt (param-changed) geometry.
666        if self.transform_gizmo.drag.is_none() {
667            match self.transform_gizmo.mode {
668                GizmoMode::Transform => self.sync_transform_gizmo(),
669                GizmoMode::Dimension => self.refresh_feature_dimension_overlay(),
670                GizmoMode::None => {}
671            }
672        }
673        // Rebuild the persistent committed-sketch overlays against the reconciled
674        // scene (also covers `set_history_json`, which returns this call's result).
675        self.refresh_committed_sketches();
676        // Rebuild the persistent construction datum/plane overlays from the frames
677        // the run just surfaced (D/P features only; sketches render as curves).
678        self.refresh_construction_datums();
679        // Rebuild the BOARD's 3D bodies from the `pcb` block, for the same reason
680        // the sketch sheets are rebuilt here: the reconcile above kept only the
681        // run's own outputs. A document with no `pcb` block returns immediately
682        // inside.
683        self.refresh_board_geometry();
684        // Assembly documents: adopt the component projection and the solved
685        // constraint state the runner shipped, and fold that state back into the
686        // document (the pose-authority contract — see `assembly_ops`).
687        // Componentless documents return immediately inside.
688        self.adopt_assembly_sync();
689        // Component selection needs the new member projection above, not the
690        // previous run's assembly. This also completes a deferred Move Copy.
691        // Keep an armed COMPONENT Move gizmo glued to its (possibly re-solved)
692        // component: re-anchor at the fresh member bbox. Never during its own
693        // drag — the drag feed owns the widget frame (free-move live-follow).
694        if self.component_move.drag.is_none() {
695            self.component_move_sync();
696        }
697        // Rebuild the assembly-constraint viewport overlays from the rows the
698        // same reply carried (an inert no-op — empty group — for a document with
699        // no assembly state). ORDER MATTERS: the overlay read must follow the
700        // adopt.
701        self.refresh_constraint_overlay();
702        // PMI: re-pose the active view's exploded solids on the fresh displays
703        // and re-bake its annotation overlay from the run's report.
704        self.pmi_after_apply();
705        // Re-derive every display colour from the metadata store, LAST — after
706        // the sketch sheets, the datum overlays and the assembly sync have all
707        // settled the scene, so no display inserted above is missed. This is why
708        // a model colour now survives a feature edit: the freshly tessellated
709        // display arrives colourless and is re-coloured from the store, instead
710        // of the colour living only on the display that was just thrown away.
711        // A no-op when nothing changed, which is the common case.
712        self.sync_colors_from_metadata();
713    }
714
715    /// Load a whole history document (a saved part file parses as one); the
716    /// engine now OWNS this recipe. Rolls to the last feature and builds it.
717    ///
718    /// The document's top-level `metadata` field (the Properties-panel
719    /// name-keyed store) is lifted out into [`Self::metadata`] before the feature
720    /// list is handed to the kernel — loading a part REPLACES the store wholesale
721    /// (a document with no `metadata` clears it), mirroring the previous metadata
722    /// manager's load semantics. Round-trips with [`Self::history_request_json`].
723    ///
724    /// The top-level `workbench` field (the ACTIVE-WORKBENCH id the save embedded
725    /// — see [`Self::history_request_json`]) is lifted off the kernel recipe the
726    /// same way and applied through [`Self::apply_settings_json`] — the SAME seam
727    /// the toolbar's workbench dropdown writes through — so the palette / context
728    /// offers / workbench buttons react to a restored workbench exactly as they
729    /// do to a manual switch (settings generation bump included). Tolerances:
730    ///
731    /// * a legacy document WITHOUT the field (or with a non-string value) leaves
732    ///   the current workbench untouched — opening an old file never yanks the
733    ///   user out of their workbench;
734    /// * an unknown/stale id is stored RAW (never an error): the settings layer
735    ///   deliberately doesn't validate ids, and every consumer resolves through
736    ///   the app-side registry's `resolve()`, which falls back to the default
737    ///   workbench — so a file saved by a build with a workbench this build
738    ///   doesn't know still opens cleanly;
739    /// * the restored id is deliberately NOT persisted to the settings blob —
740    ///   that blob stays the user's boot preference; a document's workbench is
741    ///   session-scoped (the next explicit dropdown change persists as usual).
742    pub fn set_history_json(&mut self, request_json: &str) -> Result<String, String> {
743        // A locked history (a PLM revision this user may not change) is not
744        // replaced either: a fresh `History` would arrive unlocked, which is the
745        // one door `History::lock` cannot hold by itself. Opening another
746        // document is a new engine, and a reload after checkout unlocks first.
747        if let Some(reason) = self.history.locked() {
748            return Err(format!("this document is read-only: {reason}"));
749        }
750        // A document switch is a wholesale model replacement: drop the incremental
751        // cache so the new model starts from a clean slate (no cross-document
752        // staleness, no unbounded cache growth across many opens). The roll/edit
753        // hot path (`rerun_history`) deliberately KEEPS the cache for instant
754        // rollback; this is the ONE place the full clear belongs.
755        brep_kernel::clear_history_cache();
756        // Reset the delta runner's baseline in lockstep with the cache clear so the
757        // new document is a FULL rebuild (no reuse against the prior model's names).
758        self.runner.reset();
759        self.superseded_displays.clear();
760        self.pending_mesh_imports.clear();
761        self.mesh_preview_results.clear();
762        self.pending_topology.clear();
763        self.topology_asked.clear();
764        self.scene.clear_exact_solids();
765        // A probed-but-unconsumed STEP assembly belongs to the document being
766        // replaced: importing it into the NEW one would land a file the user never
767        // chose here (and hold its solids resident until they did). A probe
768        // still running is likewise the old document's: its answer is dropped.
769        self.pending_step_assembly = None;
770        self.pending_step_probes.clear();
771        self.step_probe_results.clear();
772        // Everything the previous document's run shipped belongs to THAT
773        // document: the assembly tail, the component projection, its consumed
774        // names, and the main-side session's generation stamp. The new
775        // document's first reply replaces them; until it lands, nothing must
776        // read the old one's.
777        self.assembly_sync = None;
778        self.assembly_components.clear();
779        self.assembly_session_generation = None;
780        self.consumed_names.clear();
781        // The open sheet, the open sheet form and the projection cache all
782        // name objects of the document being replaced.
783        self.forget_sheets();
784        // A preview belongs to the document being replaced.
785        self.expression_preview = None;
786        let mut document: serde_json::Value = serde_json::from_str(request_json)
787            .map_err(|error| format!("history parse: {error}"))?;
788        let thumbnail = document.as_object_mut().and_then(|o| o.remove("thumbnail"));
789        self.thumbnail_cache.replace(None);
790        // Pull `metadata` out of the document so the engine holds the single copy
791        // (kept off the History recipe the kernel executes).
792        let metadata_value = document
793            .as_object_mut()
794            .and_then(|object| object.remove("metadata"));
795        self.metadata.load_json(metadata_value.as_ref());
796        // Lift the saved active-workbench id off the kernel recipe (`metadata`'s
797        // sibling — the kernel would ignore the extra field, but the engine owns
798        // it) and apply it through the shared settings seam; see the doc comment
799        // for the legacy/unknown-id tolerances. Applied BEFORE the rebuild below
800        // so anything reading the settings post-run already sees the restored id.
801        if let Some(workbench_id) = document
802            .as_object_mut()
803            .and_then(|object| object.remove("workbench"))
804            .as_ref()
805            .and_then(serde_json::Value::as_str)
806        {
807            let _ = self.apply_settings_json(
808                &serde_json::json!({ "workbench": workbench_id }).to_string(),
809            );
810        }
811        // Stamp each sketch's per-loop ids onto its geometries before the model
812        // is built. Deriving already yields the right ids, so this renames
813        // nothing — it PERSISTS them, which is what lets a loop keep its identity
814        // when the edge the id came from is deleted. Doing it here (rather than
815        // only on sketch commit) covers every document, including one built by a
816        // script that never enters sketch mode. See the kernel's
817        // `features/sketch/loop_ids`.
818        stamp_sketch_loop_ids(&mut document);
819        // SEED this thread's kernel parts library from the document's block.
820        // The per-run request no longer carries the block (see
821        // `History::parts_library`), so this explicit install is the ONE door
822        // that seeds a loaded document — it is what `parts_library_json()`
823        // (SAVE), the mutation-path session re-run and the export lanes all
824        // resolve ACOMPs against, and it bumps the revision so the next run
825        // hands the fresh library to the background runner. A run that heals or
826        // GCs it ships the result back (`apply_run_output`).
827        let library = document
828            .get("partsLibrary")
829            .and_then(|block| serde_json::from_value(block.clone()).ok())
830            .unwrap_or_default();
831        brep_kernel::install_parts_library(&library);
832        self.history = History::from_request_json(&document.to_string())?;
833        self.document_thumbnail.replace(thumbnail.map(|image| (self.history.edit_serial(), image)));
834        Ok(self.rerun_history())
835    }
836
837    /// The whole history request document (persistence / debugging), with the
838    /// engine-owned extras folded back in on top of the kernel recipe so
839    /// save→open round-trips them:
840    ///
841    /// * `metadata` — the Properties-panel store, written only when non-empty so
842    ///   an un-annotated model persists as before;
843    /// * `workbench` — the CURRENT active-workbench id
844    ///   (`self.settings.workbench`), ALWAYS written so a saved part reopens in
845    ///   the workbench it was saved from (restored by
846    ///   [`Self::set_history_json`]). Always-embed keeps the invariant simple:
847    ///   the serialized field tracks the LIVE setting, never a stale stored copy
848    ///   — the load lifts it off the kernel recipe entirely, so this is the ONE
849    ///   place it is (re)written. Note the deliberate consequence: switching
850    ///   workbench changes this document, so the file panel's dirty flag flips —
851    ///   consistent with the `metadata` precedent, and semantically true now
852    ///   that the workbench is part of the saved file.
853    pub fn history_request_json(&self) -> String {
854        let mut document: serde_json::Value =
855            serde_json::from_str(&self.history.request_json())
856                .unwrap_or_else(|_| serde_json::json!({}));
857        if let Some(object) = document.as_object_mut() {
858            if !self.metadata.is_empty() {
859                object.insert("metadata".into(), self.metadata.to_json());
860            }
861            object.insert("thumbnail".into(), self.model_thumbnail());
862            object.insert(
863                "workbench".into(),
864                serde_json::Value::String(self.settings.workbench.clone()),
865            );
866        }
867        document.to_string()
868    }
869
870    /// Cache the small portable PNG by scene content, not by camera position.
871    /// The picture is the DOCUMENT's, so it is rendered only while the scene is
872    /// the document's model as built. A run the scene has not caught up with
873    /// (an edit whose rebuild is still to come) gets an explicitly pending,
874    /// empty image rather than a picture of the previous geometry; plugin work
875    /// in flight is not that, since a candidate keeps its own scene until it
876    /// commits. A transient presentation — an expression preview (a family
877    /// member shown in the document's place) or an illustration — gets the
878    /// document's own last picture (the one it was opened with, or the one its
879    /// last save rendered) rather than what is on screen. A just-opened file
880    /// keeps its embedded image while its unchanged recipe is being rebuilt.
881    /// The document's picture is keyed by the history's edit serial, which
882    /// moves on user edits, undo and redo only (a run's write-back ticks the
883    /// revision). The same built model renders the same bytes every time.
884    fn model_thumbnail(&self) -> serde_json::Value {
885        let pending = self.run_generation != self.applied_generation();
886        let transient = self.expression_preview.is_some() || self.illustration_snapshot.is_some();
887        let mut key = vec![self.history.revision(), self.applied_generation(),
888            self.scene.visibility_revision(), self.settings_generation, pending as u64, transient as u64];
889        key.extend(self.scene.solids().iter().map(|s| s.revision));
890        if let Some((old, image)) = self.thumbnail_cache.borrow().as_ref() {
891            if *old == key { return image.clone(); }
892        }
893        let image = if pending || transient {
894            self.document_thumbnail.borrow().as_ref().filter(|(serial, _)| *serial == self.history.edit_serial())
895                .map(|(_, image)| image.clone())
896                .unwrap_or_else(|| crate::thumbnail::empty_embedded("pending"))
897        } else {
898            let capture = crate::thumbnail::capture(&self.scene);
899            let image = match crate::thumbnail::render_png(&capture, crate::thumbnail::SIZE) {
900                Some(png) => crate::thumbnail::embedded_png(&png, crate::thumbnail::SIZE, "ready"),
901                None => crate::thumbnail::empty_embedded("empty"),
902            };
903            self.document_thumbnail.replace(Some((self.history.edit_serial(), image.clone())));
904            image
905        };
906        self.thumbnail_cache.replace(Some((key, image.clone())));
907        image
908    }
909
910    /// The tree listing `{ step, features:[{index,type,id}] }` for the UI panel.
911    pub fn history_listing_json(&self) -> String {
912        self.history.listing_json()
913    }
914
915    /// The last build report JSON.
916    pub fn history_report_json(&self) -> String {
917        self.history_report.clone()
918    }
919
920    pub fn history_len(&self) -> usize {
921        self.history.len()
922    }
923
924    /// The rolled-to (selected) feature index.
925    pub fn history_rollback(&self) -> usize {
926        self.history.rollback()
927    }
928
929    /// A monotonic counter of mutation opportunities on the history document —
930    /// the cache key for anything derived from it. See [`crate::history::History::revision`].
931    pub fn history_revision(&self) -> u64 {
932        self.history.revision()
933    }
934
935    pub fn feature_type_at(&self, index: usize) -> Option<String> {
936        self.history.feature_type(index)
937    }
938
939    pub fn feature_id_at(&self, index: usize) -> Option<String> {
940        self.history.feature_id(index)
941    }
942
943    /// The `inputParams` document of feature `index` (`"null"` if none) — the
944    /// dialog's editing-buffer source.
945    pub fn feature_params_json(&self, index: usize) -> String {
946        self.history
947            .feature_params(index)
948            .map(|v| v.to_string())
949            .unwrap_or_else(|| "null".to_string())
950    }
951
952    /// Mint the id for a NEW feature: `{base}{N}` where `base` is the feature's
953    /// shortName ([`crate::features::feature_short_name`]) and `N` is the part
954    /// history's persistent GLOBAL counter (monotonic, never reused, round-trips
955    /// save/load — see [`History::next_feature_id`]). `&mut` because the counter
956    /// advances; if the caller's `add_feature` then fails the number is simply
957    /// skipped (monotonic-with-gaps is the contract, not an error).
958    pub fn next_feature_id(&mut self, base: &str) -> String {
959        self.history.next_feature_id(base)
960    }
961
962    /// Adopt an edited document as one undo step and rebuild through the normal
963    /// runner, preserving the tab's scene settings and history stack.
964    pub fn edit_document_json(&mut self, document: &str) -> Result<String, String> {
965        let value: serde_json::Value = serde_json::from_str(document).map_err(|e| e.to_string())?;
966        let library = value.get("partsLibrary").map(|v| serde_json::from_value(v.clone()))
967            .transpose().map_err(|e| format!("parts library: {e}"))?;
968        self.history.adopt_document_checkpointed(document)?;
969        // The runner's library channel reads the resident store. Updating only
970        // History left it serving the old snapshot after Update Components.
971        if let Some(library) = library {
972            brep_kernel::install_parts_library(&library);
973        }
974        Ok(self.rerun_history())
975    }
976
977    /// Publish derived changes folded into the current user edit, without adding
978    /// another undo step (for example PCB placement and its assembly pose).
979    pub fn rebuild_current_history(&mut self) -> String {
980        self.rerun_history()
981    }
982
983    /// Roll the model to feature `index`: re-run `features[0..=index]`.
984    pub fn roll_to(&mut self, index: usize) -> String {
985        self.history.set_rollback(index);
986        self.rerun_history()
987    }
988
989    /// Replace feature `id`'s input params and re-run at the current rollback →
990    /// the viewport updates live.
991    pub fn update_feature_params(
992        &mut self,
993        id: &str,
994        input_params_json: &str,
995    ) -> Result<String, String> {
996        let mut params: serde_json::Value = serde_json::from_str(input_params_json)
997            .map_err(|e| format!("feature params parse: {e}"))?;
998        let index = self
999            .history
1000            .index_of(id)
1001            .ok_or_else(|| format!("no feature with id '{id}'"))?;
1002        self.stamp_face_transform_pivot(index, &mut params);
1003        self.history.set_feature_params(index, params);
1004        Ok(self.rerun_history())
1005    }
1006
1007    /// Replace the `inputParams` of MANY features as ONE model edit — the
1008    /// [`Self::update_feature_params`] batch sibling ([`Self::add_features`] is
1009    /// the append-only one). ONE undo checkpoint and ONE history re-run for the
1010    /// whole set.
1011    ///
1012    /// The lane that needs it: a PACKED BOM row rolls up every occurrence whose
1013    /// occurrence data matches, so editing one of its cells writes the same key
1014    /// into N ACOMP features. Looping `update_feature_params` would cost N
1015    /// re-runs and — worse — N undo entries, so taking back one visible edit
1016    /// would need N presses of undo.
1017    ///
1018    /// Unknown ids are reported (the whole batch is refused before anything is
1019    /// written, so a typo can never half-apply); an empty batch is a no-op that
1020    /// neither checkpoints nor runs.
1021    pub fn update_many_feature_params(
1022        &mut self,
1023        edits: &[(String, serde_json::Value)],
1024    ) -> Result<String, String> {
1025        if edits.is_empty() {
1026            return Ok(self.history_report.clone());
1027        }
1028        let mut resolved: Vec<(usize, serde_json::Value)> = Vec::with_capacity(edits.len());
1029        for (id, params) in edits {
1030            let index = self
1031                .history
1032                .index_of(id)
1033                .ok_or_else(|| format!("no feature with id '{id}'"))?;
1034            let mut params = params.clone();
1035            self.stamp_face_transform_pivot(index, &mut params);
1036            resolved.push((index, params));
1037        }
1038        self.history.set_many_feature_params(&resolved);
1039        Ok(self.rerun_history())
1040    }
1041
1042    /// Append a feature (a full `{type, inputParams, …}` descriptor) and roll to
1043    /// it. The caller assigns a unique `id` (see [`Self::next_feature_id`]).
1044    pub fn add_feature(&mut self, feature_json: &str) -> Result<String, String> {
1045        let feature: serde_json::Value =
1046            serde_json::from_str(feature_json).map_err(|e| format!("feature parse: {e}"))?;
1047        if !self.append_plugin_features(std::slice::from_ref(&feature))? {
1048            self.history.push_feature(feature);
1049        }
1050        let last = self.history.len().saturating_sub(1);
1051        self.history.set_rollback(last);
1052        Ok(self.rerun_history())
1053    }
1054
1055    /// Append MANY features and roll to the last — [`Self::add_feature`]'s batch
1056    /// sibling, and the reason it exists: `add_feature` re-runs the WHOLE history
1057    /// per call, so a lane that appends N features by looping it costs N rebuilds
1058    /// (O(N²) work on an N-part STEP-assembly import). This pushes all of them,
1059    /// then re-runs ONCE — one rebuild, one undo checkpoint, one
1060    /// [`applied_generation`](Self::applied_generation) bump.
1061    ///
1062    /// Deliberately NOT [`Self::set_history_json`]: that is the document-SWITCH
1063    /// path (it clears the kernel history cache and resets the runner's delta
1064    /// baseline, forcing a full cold rebuild), which is the wrong mechanism for an
1065    /// append onto the live document.
1066    ///
1067    /// An empty batch is a no-op — no checkpoint, no run, no generation bump —
1068    /// and returns the standing report. The caller assigns each feature's unique
1069    /// `id` (see [`Self::next_feature_id`]).
1070    pub fn add_features(&mut self, features: &[serde_json::Value]) -> String {
1071        if features.is_empty() {
1072            return self.history_report.clone();
1073        }
1074        match self.append_plugin_features(features) {
1075            Ok(true) => {},
1076            Ok(false) => self.history.push_features(features.to_vec()),
1077            Err(error) => return serde_json::json!({"error":error}).to_string(),
1078        }
1079        let last = self.history.len().saturating_sub(1);
1080        self.history.set_rollback(last);
1081        self.rerun_history()
1082    }
1083
1084    /// Delete the feature with id `id` (no-op if absent) and re-run, clamping the
1085    /// rolled-to step.
1086    pub fn delete_feature(&mut self, id: &str) -> String {
1087        match self.history.index_of(id) {
1088            Some(index) => self.delete_feature_at(index),
1089            None => self.rerun_history(),
1090        }
1091    }
1092
1093    /// Delete the feature at `index` (no-op when out of range) and re-run,
1094    /// clamping the rolled-to step.
1095    ///
1096    /// The POSITIONAL twin of [`Self::delete_feature`], and the only way to
1097    /// remove a feature whose `inputParams` carry no `id` — a shape a hand-built
1098    /// history JSON can still contain, and one an id-keyed delete can never
1099    /// address.
1100    pub fn delete_feature_at(&mut self, index: usize) -> String {
1101        if index < self.history.len() {
1102            self.history.remove_feature(index);
1103            let step = self
1104                .history
1105                .rollback()
1106                .min(self.history.len().saturating_sub(1));
1107            self.history.set_rollback(step);
1108        }
1109        self.rerun_history()
1110    }
1111
1112    /// Move feature `index` one slot up/down (reorder), keeping it selected.
1113    pub fn reorder_feature(&mut self, index: usize, up: bool) -> String {
1114        let len = self.history.len();
1115        if len >= 2 {
1116            let target = if up {
1117                index.checked_sub(1)
1118            } else if index + 1 < len {
1119                Some(index + 1)
1120            } else {
1121                None
1122            };
1123            if let Some(target) = target {
1124                self.history.swap(index, target);
1125                self.history.set_rollback(target);
1126            }
1127        }
1128        self.rerun_history()
1129    }
1130
1131}
1132
1133impl EngineState {
1134    /// Whether an undo step is available (to enable the toolbar's Undo button).
1135    pub fn can_undo(&self) -> bool {
1136        self.history.can_undo()
1137    }
1138
1139    /// Whether a redo step is available.
1140    pub fn can_redo(&self) -> bool {
1141        self.history.can_redo()
1142    }
1143
1144    /// Undo the last model mutation: restore the previous document + rolled-to
1145    /// step, then re-run + reconcile the scene. Returns the build report; a no-op
1146    /// (empty undo stack) returns the last report unchanged.
1147    pub fn undo(&mut self) -> String {
1148        if self.history.undo() {
1149            self.reinstall_rewound_parts_library();
1150            self.rerun_history()
1151        } else {
1152            self.history_report.clone()
1153        }
1154    }
1155
1156    /// Redo the last undone model mutation (symmetric with [`Self::undo`]).
1157    pub fn redo(&mut self) -> String {
1158        if self.history.redo() {
1159            self.reinstall_rewound_parts_library();
1160            self.rerun_history()
1161        } else {
1162            self.history_report.clone()
1163        }
1164    }
1165
1166    /// Push the just-rewound `partsLibrary` block back into the kernel's
1167    /// main-side store, so time travel moves the LIBRARY with the document.
1168    ///
1169    /// Without this, undo only half-works on anything that edits a library
1170    /// entry. The undo snapshot carries the block (`History::Snapshot`), but the
1171    /// block is a MIRROR: the per-run request does not carry it
1172    /// ([`History::prefix_request`] omits it) and a run that heals or GCs the
1173    /// store writes its result back over the block (`apply_run_output`). So a
1174    /// rewound block that was never pushed into the store is simply overwritten
1175    /// again, and the undo silently does nothing — which is what
1176    /// [`Self::set_part_attribute`](crate::engine_state::EngineState::set_part_attribute)
1177    /// would hit on its first undo.
1178    ///
1179    /// `install_parts_library` matches the store to the block BY CONTENT
1180    /// IDENTITY: an identical entry is kept verbatim (so a heal this side
1181    /// derived is not clobbered), a changed one is replaced and marked dirty
1182    /// (so the ACOMP self-heal re-derives every instance), and one absent from
1183    /// the block is dropped. An unchanged block is therefore a no-op, which is
1184    /// the overwhelmingly common undo.
1185    pub(super) fn reinstall_rewound_parts_library(&mut self) {
1186        let library = serde_json::from_value(self.history.parts_library().clone())
1187            .unwrap_or_default();
1188        brep_kernel::install_parts_library(&library);
1189    }
1190
1191    // --- Selection (Esc clears / viewport click selects) ------------------
1192
1193}
1194
1195/// Stamp per-loop ids onto every SKETCH feature's geometries in a history
1196/// document, in place.
1197///
1198/// The persistence half of per-loop face naming. The kernel DERIVES a loop's id
1199/// the same way every run, so this renames nothing; what it adds is durability —
1200/// a stored id survives deleting the edge it was originally derived from, which
1201/// derivation alone cannot. Running it on document LOAD (not only on sketch
1202/// commit) means a model built by a script, an import, or any other path that
1203/// never opens the sketch editor still gets its ids written down.
1204///
1205/// Assemblies: a parts-library entry embeds a FULL sub-part history
1206/// (`partsLibrary[*].document`), whose features can include sketches of its own,
1207/// so the walk recurses into each one. Without that, a sketch inside an imported
1208/// assembly part would derive correct names but carry no stored ids — exactly the
1209/// case (deleting the edge an id came from) that persisting exists to cover.
1210/// Depth is bounded: a sub-document's own library is seeded from the kernel store
1211/// rather than nested inside the entry, so one level of recursion reaches all of
1212/// them, and `MAX_LIBRARY_DEPTH` stops a malformed self-referential document.
1213///
1214/// Malformed features are skipped rather than rejected: this is a best-effort
1215/// enrichment on the way to the kernel, which validates the document itself.
1216fn stamp_sketch_loop_ids(document: &mut serde_json::Value) {
1217    /// Depth cap for the embedded sub-document walk — a guard against a
1218    /// hand-edited or corrupt document that nests libraries into each other.
1219    const MAX_LIBRARY_DEPTH: usize = 8;
1220    stamp_sketch_loop_ids_to_depth(document, MAX_LIBRARY_DEPTH);
1221}
1222
1223/// [`stamp_sketch_loop_ids`] with the remaining recursion budget.
1224fn stamp_sketch_loop_ids_to_depth(document: &mut serde_json::Value, depth: usize) {
1225    if let Some(features) = document
1226        .get_mut("features")
1227        .and_then(serde_json::Value::as_array_mut)
1228    {
1229        for feature in features {
1230            if feature.get("type").and_then(serde_json::Value::as_str) != Some("S") {
1231                continue;
1232            }
1233            let Some(sketch) = feature
1234                .get_mut("persistentData")
1235                .and_then(|data| data.get_mut("sketch"))
1236            else {
1237                continue;
1238            };
1239            brep_kernel::assign_sketch_loop_ids(sketch);
1240        }
1241    }
1242    if depth == 0 {
1243        return;
1244    }
1245    // Each library entry's embedded sub-part history gets the same treatment.
1246    let Some(library) = document
1247        .get_mut("partsLibrary")
1248        .and_then(serde_json::Value::as_object_mut)
1249    else {
1250        return;
1251    };
1252    for (_, entry) in library.iter_mut() {
1253        let Some(embedded) = entry.get_mut("document") else {
1254            continue;
1255        };
1256        stamp_sketch_loop_ids_to_depth(embedded, depth - 1);
1257    }
1258}
1259
1260impl EngineState {
1261    /// Load a whole model document (a saved `.nbrep` recipe) and FRAME it:
1262    /// [`set_history_json`](Self::set_history_json) (which rolls to the last
1263    /// feature) followed by [`zoom_to_fit`](Self::zoom_to_fit). The one call the
1264    /// file panel's **Open** needs — the model IS the engine-owned history, so
1265    /// opening a file is loading its request JSON and reframing. Returns the
1266    /// build-report JSON.
1267    pub fn load_model_and_fit(&mut self, request_json: &str) -> Result<String, String> {
1268        // Frame once the run lands, not now: under a background runner (native
1269        // thread / wasm worker) the freshly loaded model is not resident yet when
1270        // `set_history_json` returns, so an immediate `zoom_to_fit` would frame the
1271        // OLD scene. Set BEFORE the submit so the Inline runner's in-call `pump`
1272        // still frames synchronously. See [`EngineState::pending_fit`].
1273        self.pending_fit = true;
1274        let result = self.set_history_json(request_json);
1275        if result.is_err() {
1276            // A rejected document submits no run, so the armed fit would otherwise
1277            // fire on the OLD scene next frame — disarm it.
1278            self.pending_fit = false;
1279        }
1280        result
1281    }
1282}
1283
1284
1285
1286
1287
1288
1289/// The run report's `featureRefusals` object, added only when a feature failed
1290/// with a TYPED refusal: feature id → the kernel's refusal as it serializes
1291/// (`class` and its payload, `stage`, `message` — the text `featureErrors`
1292/// carries for the same feature) plus `step`, the motion step that refused,
1293/// when the feature records one (Transform Face: `rotation` / `translation`).
1294/// Absent otherwise, so a report with no typed refusal is byte-for-byte what it
1295/// was before this key existed.
1296fn with_refusals(
1297    mut value: serde_json::Value,
1298    report: &crate::pipeline::SceneBuildReport,
1299) -> serde_json::Value {
1300    if report.feature_refusals.is_empty() {
1301        return value;
1302    }
1303    let refusals: serde_json::Map<String, serde_json::Value> = report
1304        .feature_refusals
1305        .iter()
1306        .map(|(id, refusal, step)| {
1307            let mut value = serde_json::to_value(refusal).unwrap_or(serde_json::Value::Null);
1308            if let (Some(step), serde_json::Value::Object(object)) = (step, &mut value) {
1309                object.insert("step".into(), serde_json::Value::String(step.clone()));
1310            }
1311            (id.clone(), value)
1312        })
1313        .collect();
1314    if let serde_json::Value::Object(object) = &mut value {
1315        object.insert("featureRefusals".into(), serde_json::Value::Object(refusals));
1316    }
1317    value
1318}
1319
1320/// The run report's `featureApproximations` object, added only when a feature
1321/// SUCCEEDED carrying a MEASURED approximation: feature id → the kernel's
1322/// approximations as they serialize (`code`, `body`, `measured`, `bar`,
1323/// `volume_bound`, `edges[{edge_id, curve_ref, face_ref, surface_ref,
1324/// off_carrier_mm, origin}]`, `message`), each plus a one-line `summary` the
1325/// history tree shows as the feature's leaf. Absent otherwise, so a report with
1326/// no approximation is byte-for-byte what it was before this key existed — the
1327/// same contract as `featureRefusals`. Distinct from `featureRefusals` (the
1328/// result does not stand) and `featureFulfilment` (what was done of what was
1329/// asked): an approximation's result stands, and this says by how much it is
1330/// not exact.
1331fn with_approximations(
1332    mut value: serde_json::Value,
1333    report: &crate::pipeline::SceneBuildReport,
1334) -> serde_json::Value {
1335    if report.feature_approximations.is_empty() {
1336        return value;
1337    }
1338    let approximations: serde_json::Map<String, serde_json::Value> = report
1339        .feature_approximations
1340        .iter()
1341        .map(|(id, approximations)| {
1342            let entries: Vec<serde_json::Value> = approximations
1343                .iter()
1344                .map(|approximation| {
1345                    let mut value =
1346                        serde_json::to_value(approximation).unwrap_or(serde_json::Value::Null);
1347                    if let serde_json::Value::Object(object) = &mut value {
1348                        object.insert(
1349                            "summary".into(),
1350                            serde_json::Value::String(approximation_summary(approximation)),
1351                        );
1352                    }
1353                    value
1354                })
1355                .collect();
1356            (id.clone(), serde_json::Value::Array(entries))
1357        })
1358        .collect();
1359    if let serde_json::Value::Object(object) = &mut value {
1360        object.insert("featureApproximations".into(), serde_json::Value::Object(approximations));
1361    }
1362    value
1363}
1364
1365/// One line for an approximation, for the history tree's leaf: the marker
1366/// word, the code, the body, the measurement against its bar and the volume
1367/// bound when the code implies one. Code-agnostic on purpose — the units of
1368/// `measured` are the code's (mm² for `import.shell_closure`), so only the
1369/// volume bound, which every code states in mm³, carries a unit here; the
1370/// kernel's `message` has the full text with units, faces and edges.
1371pub fn approximation_summary(approximation: &brep_kernel::Approximation) -> String {
1372    let ratio = if approximation.bar > 0.0 {
1373        format!(" ({:.1}x)", approximation.measured / approximation.bar)
1374    } else {
1375        String::new()
1376    };
1377    let bound = approximation
1378        .volume_bound
1379        .map(|bound| format!("; volume determined to ±{bound:.3e} mm³"))
1380        .unwrap_or_default();
1381    format!(
1382        "approximate ({}): {} measured {:.3e} against a bar of {:.3e}{ratio}{bound}",
1383        approximation.code, approximation.body, approximation.measured, approximation.bar
1384    )
1385}
1386
1387/// The run report's `featureFulfilment` object: feature id → the typed
1388/// fulfilment (`requested`, `applied`, `rejected[{name, kind, reason}]`) plus a
1389/// one-line `summary` the tree and the automation report show verbatim. Only the
1390/// features that reported one appear, so an absent key means "did everything".
1391fn fulfilment_map(report: &crate::pipeline::SceneBuildReport) -> serde_json::Map<String, serde_json::Value> {
1392    report
1393        .feature_fulfilment
1394        .iter()
1395        .map(|(id, fulfilment)| {
1396            let mut value = serde_json::to_value(fulfilment).unwrap_or(serde_json::Value::Null);
1397            if let serde_json::Value::Object(object) = &mut value {
1398                object.insert("summary".into(), serde_json::Value::String(fulfilment.summary()));
1399            }
1400            (id.clone(), value)
1401        })
1402        .collect()
1403}