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