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