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    // --- Engine-owned history (roll-to-step + edit + add/delete/reorder) ---
27    //
28    // The editable model recipe lives HERE (`self.history`) — the UI keeps no
29    // copy of it. It edits + reads the model exclusively through these methods,
30    // so the history is UI-agnostic and converges with the "whole history in
31    // Rust" pipeline migration.
32
33    /// (Re)run the current rolled-to prefix of the engine's history through the
34    /// SAME kernel pipeline and reconcile the display scene. Stores + returns the
35    /// build report JSON.
36    pub(super) fn rerun_history(&mut self) -> String {
37        // Roll-to-step re-runs a TRUNCATED prefix each time and RELIES on the
38        // kernel's incremental history cache so an unchanged upstream prefix (a
39        // heavy STEP import + primitives) replays instantly (`reused`, timing 0.0)
40        // instead of cold re-executing — that is what makes rollback/edit feel
41        // instant. The cache is NOT cleared here.
42        //
43        // Correctness of rolling BEFORE a boolean that consumed its target: a
44        // consumed input handle is freed exactly when its PRODUCING cache entry is
45        // invalidated (`free_entry`), never by a downstream consumer — a boolean
46        // clones its inputs and frees only its own intermediates (verified across
47        // boolean/transform/hole/pattern/…). So the box's handle is still resident
48        // and rolling to it shows the box, not a freed/re-used handle. Regression
49        // guard: `history_cache_rollback_tests`. A wholesale document switch
50        // (`set_history_json`) still clears the cache — the roll/edit hot path does
51        // NOT.
52        //
53        // The M2a seam: SUBMIT the history run tagged with a monotonic generation,
54        // then `pump` drains its completed reply and APPLIES the delta. For the
55        // synchronous [`InlineRunner`](crate::runner::InlineRunner) the submit runs
56        // immediately and `pump` applies it in THIS call, so behavior stays
57        // byte-identical to the pre-seam in-place reconcile; a later milestone makes
58        // the runner a background thread/worker whose reply `pump` applies a frame
59        // later (the per-frame `pump` in the app drives that path).
60        let request_value = self.history.prefix_request();
61        match serde_json::from_value::<HistoryRequest>(request_value) {
62            Ok(mut request) => {
63                // Carry the live display LOD to the runner (the request is the run
64                // boundary the thread/worker receives). The runner re-tessellates
65                // every resident mesh when this differs from its last run's lod.
66                request.display_lod = self.settings.lod_factor;
67                self.run_generation += 1;
68                // A new run supersedes a cancelled one's notice.
69                self.cancelled_run = None;
70                // The PARTS-LIBRARY CHANNEL. A background runner (native
71                // thread / browser worker) owns its own kernel store, so it
72                // needs the library — but sending it WITH every run meant
73                // stringifying every embedded part payload on the UI thread
74                // for every edit, which froze the browser on an imported STEP
75                // assembly. It is sent only when it CHANGES; `fetch` does not
76                // run in the steady state. Inline shares this thread's store
77                // and no-ops.
78                self.runner.sync_parts_library(
79                    brep_kernel::parts_library_revision(),
80                    &mut brep_kernel::parts_library_map,
81                );
82                self.runner.submit_run(request, self.run_generation);
83            }
84            // A parse failure runs nothing: clear the surfaced frames/profiles (so
85            // stale construction geometry does not linger — the scene keeps its
86            // previous solids) and set an error report, then run the shared
87            // post-apply tail synchronously (no kernel work), so this branch shares
88            // the dirty/gizmo/overlay continuation verbatim with a real apply.
89            Err(error) => {
90                self.construction_frames.clear();
91                self.sketch_profiles.clear();
92                self.sketch_paths.clear();
93                self.sketch_points.clear();
94                self.sketch_axes.clear();
95                self.finish_apply(
96                    serde_json::json!({ "error": format!("history request: {error}") }).to_string(),
97                );
98            }
99        }
100        // Inline applies the submitted run NOW; a thread impl would defer it to a
101        // later frame's `pump`. Either way `history_report` is fresh once the reply
102        // is applied — for Inline that is before this call returns.
103        self.pump();
104        self.history_report.clone()
105    }
106
107    /// Drain every completed run reply and APPLY it — the POLL/APPLY half of the
108    /// M2a seam. Called from [`rerun_history`](Self::rerun_history) for the Inline
109    /// runner's immediate apply, and once per frame from the app so a future async
110    /// runner's completed runs land on the main thread. A reply older than
111    /// [`applied_generation`](Self::applied_generation) (a newer run that finished
112    /// first) is dropped.
113    pub fn pump(&mut self) {
114        // The runner REFUSED a run because its resident parts library could not
115        // serve it (see `Reply::NeedPartsLibrary`). It has already forgotten
116        // its copy, so re-running re-installs the library and re-submits. This
117        // is a real path, not just a tripwire: the kernel's orphan GC drops
118        // entries at the end of every run, so an undo to zero components empties
119        // the RUNNER's store while this side's (which never ran) keeps
120        // everything — no revision bookkeeping can see that, only the runner's
121        // content preflight can. `library_resync` breaks the rerun→pump→rerun
122        // recursion (the reinstall makes the second attempt succeed, but a
123        // guard beats relying on that). The guard is tested FIRST because
124        // `poll_library_request` CONSUMES the flag — polling it while a resync
125        // is already in flight would swallow a second refusal.
126        if !self.library_resync && self.runner.poll_library_request() {
127            self.library_resync = true;
128            self.rerun_history();
129            self.library_resync = false;
130        }
131        while let Some(reply) = self.runner.poll_mesh_import() {
132            // A document switch clears this set. Ignore any older reconstruction
133            // reply that was already running when its Reset crossed the queue.
134            let Some(destination) = self.pending_mesh_imports.remove(&reply.id) else {
135                continue;
136            };
137            if destination == MeshImportDestination::Preview {
138                self.mesh_preview_results.push_back(reply);
139                continue;
140            }
141            match reply.result {
142                Ok(output) => match self.import_step_feature(&output.step_text) {
143                    Ok(_) => self.push_notice(
144                        "RANSAC reconstruction complete; building imported CAD body",
145                    ),
146                    Err(error) => self.push_notice(format!("mesh import failed: {error}")),
147                },
148                Err(error) => self.push_notice(format!("mesh import failed: {error}")),
149            }
150        }
151        // STEP probes: the parse ran on the runner; stash the structure it
152        // found (the import consumes it) and queue the outcome for the panel.
153        while let Some(reply) = self.runner.poll_step_probe() {
154            if !self.pending_step_probes.remove(&reply.id) {
155                continue; // cancelled, or a document switch — nobody is waiting
156            }
157            let outcome = match reply.result {
158                Ok(Some(assembly)) => {
159                    let probe = super::model_io::probe_counts(&assembly);
160                    // The kernel already refuses a structure that reaches no
161                    // geometry, so this is belt-and-braces: an assembly with
162                    // zero instances would import as zero components, which
163                    // is the silent failure the structured lane forbids.
164                    if probe.instances == 0 {
165                        super::StepProbeOutcome::Flat
166                    } else {
167                        self.pending_step_assembly = Some(assembly);
168                        super::StepProbeOutcome::Structure(probe)
169                    }
170                }
171                Ok(None) => super::StepProbeOutcome::Flat,
172                Err(error) => super::StepProbeOutcome::Failed(error),
173            };
174            self.step_probe_results.push_back((reply.id, outcome));
175        }
176        // The in-flight run's latest progress report (a background runner
177        // posts one before each feature it executes). Kept only for a run
178        // newer than the applied one: a report from a superseded run — or one
179        // still arriving after a cancel bumped the generations — is stale.
180        if let Some(progress) = self.runner.poll_progress() {
181            if progress.generation > self.applied_generation {
182                self.run_progress = Some(progress);
183            }
184        }
185        while let Some(reply) = self.runner.poll_run() {
186            if reply.generation >= self.applied_generation {
187                self.applied_generation = reply.generation;
188                self.apply_run_output(reply.output);
189            }
190        }
191        if !self.run_pending() {
192            self.run_progress = None;
193        }
194        // Deferred one-shot framing (Import / Open): frame the scene the moment the
195        // run they submitted has fully landed. Consumed unconditionally once the run
196        // is no longer pending — even when it produced no solids (bbox empty →
197        // `zoom_to_fit` no-ops) — so a later unrelated run never inherits a stale fit.
198        if self.pending_fit && !self.run_pending() {
199            self.pending_fit = false;
200            self.zoom_to_fit();
201        }
202        // Drain any completed measurement replies too (a background runner surfaces
203        // them a frame after selection); for Inline this is a no-op each frame since
204        // `object_info_json` already pumped its own query same-call.
205        self.pump_queries();
206    }
207
208    /// Whether a measurement query is still in flight (its reply not yet drained) —
209    /// the query analogue of [`run_pending`](Self::run_pending), so the app keeps the
210    /// frame loop alive until a background runner's measurement lands and displays.
211    /// Always `false` for the synchronous Inline runner.
212    pub fn queries_pending(&self) -> bool {
213        !self.pending_query.is_empty()
214    }
215
216    /// Whether RANSAC reconstruction is still executing on the native runner
217    /// thread or browser worker.
218    pub fn mesh_imports_pending(&self) -> bool {
219        !self.pending_mesh_imports.is_empty()
220    }
221
222    /// Whether a submitted run has not yet been applied (`run_generation !=
223    /// applied_generation`). Always `false` for the synchronous Inline runner
224    /// (submit → immediate `pump` keeps the two in lockstep); a background runner
225    /// uses it to keep the frame loop alive until its reply lands.
226    pub fn run_pending(&self) -> bool {
227        self.run_generation != self.applied_generation
228    }
229
230    /// The generation of the last APPLIED run — bumps once per applied history
231    /// run (document loads, edits, constraint mutations, solves). A cheap
232    /// staleness key for app-side caches derived from the applied document
233    /// (the update-components outdated badge keys on it).
234    pub fn applied_generation(&self) -> u64 {
235        self.applied_generation
236    }
237
238    /// What the in-flight run is executing right now, as far as the runner
239    /// has reported (see [`crate::runner::RunProgress`]); `None` when nothing
240    /// is running or the run has not reached its first executed feature.
241    pub fn run_progress(&self) -> Option<&crate::runner::RunProgress> {
242        self.run_progress.as_ref()
243    }
244
245    /// The feature id the last cancelled run was executing (empty when it was
246    /// cancelled before any progress arrived), until the next submit.
247    pub fn cancelled_run(&self) -> Option<&str> {
248        self.cancelled_run.as_deref()
249    }
250
251    /// CANCEL the in-flight run. The runner abandons its work and comes back
252    /// with an EMPTY resident registry (a fresh thread / a fresh worker — see
253    /// [`crate::runner::HistoryRunner::cancel`]), so this side forgets
254    /// everything that was waiting on it: the run itself (generations are
255    /// bumped past it, so a straggling reply from the old runner is dropped
256    /// as stale), pending measurement queries, document-bound mesh imports
257    /// and a deferred fit. The DISPLAY SCENE is left as the last applied run
258    /// built it — the document is ahead of it now, which the notice says; the
259    /// next edit re-runs the whole history through the new runner (a cold
260    /// run: the warm cache went with the old one). Nothing inside a feature
261    /// is interruptible, so the native thread keeps burning CPU until the
262    /// feature it is on finishes; the browser worker is terminated outright.
263    ///
264    /// `false` when nothing was running, or the runner cannot abandon (the
265    /// synchronous Inline runner, whose runs are over before anyone can ask).
266    pub fn cancel_run(&mut self) -> bool {
267        if !self.run_pending() || !self.runner.cancel() {
268            return false;
269        }
270        let stalled_on = self.run_progress.take().map(|progress| progress.feature_id);
271        // Past every generation submitted so far: a reply the old runner
272        // already posted (sitting in the main event loop on wasm) carries an
273        // older number than this and is dropped by `pump`'s gate.
274        self.run_generation += 1;
275        self.applied_generation = self.run_generation;
276        self.pending_query.clear();
277        let dropped_imports = self.pending_mesh_imports.len();
278        self.pending_mesh_imports.clear();
279        let dropped_probes = self.pending_step_probes.len();
280        self.pending_step_probes.clear();
281        self.pending_fit = false;
282        self.library_resync = false;
283        self.cancelled_run = Some(stalled_on.clone().unwrap_or_default());
284        self.push_notice(match stalled_on {
285            Some(id) if !id.is_empty() => format!(
286                "Run cancelled while executing {id}. The model shows the last completed \
287                 result; edit or delete the feature to rebuild."
288            ),
289            _ => "Run cancelled. The model shows the last completed result; the next edit \
290                  rebuilds it."
291                .to_string(),
292        });
293        if dropped_imports > 0 {
294            self.push_notice(format!(
295                "cancelled {dropped_imports} pending mesh import{}",
296                if dropped_imports == 1 { "" } else { "s" }
297            ));
298        }
299        if dropped_probes > 0 {
300            self.push_notice("cancelled the STEP file being read — upload it again to import it");
301        }
302        true
303    }
304
305    /// Whether the display scene currently holds at least one solid. Used by the
306    /// app's async-safe first-frame framing: under a background runner (thread /
307    /// worker) the seed run lands a frame (or many) after boot, so the shell waits
308    /// for `has_solids() && !run_pending()` before its one-shot `zoom_to_fit`.
309    pub fn has_solids(&self) -> bool {
310        !self.scene.solids().is_empty()
311    }
312
313    /// Swap in a different history runner (the platform injects its own — the native
314    /// app installs a [`ThreadRunner`](crate::runner::ThreadRunner); wasm keeps the
315    /// default Inline until M3's worker). Resets the new runner's delta baseline so
316    /// the next run rebuilds fully. Call BEFORE seeding a document so the seed builds
317    /// through the installed runner.
318    pub fn set_runner(&mut self, runner: Box<dyn crate::runner::HistoryRunner>) {
319        self.runner = runner;
320        self.runner.reset();
321        self.pending_mesh_imports.clear();
322        self.mesh_preview_results.clear();
323    }
324
325    /// Apply a [`SceneRunner`](crate::pipeline::SceneRunner) delta to the display
326    /// scene and build the history report JSON — the APPLY half of the M2a seam.
327    ///
328    /// Reconcile preserving ORDER + reuse: MOVE every current display out of the
329    /// scene ([`RenderScene::drain`](crate::scene::RenderScene::drain)) into a
330    /// name-keyed `kept` map, then reinsert in snapshot order — a fresh entry
331    /// (`Some`) replaces, an UNCHANGED entry (`None`) reuses its moved-out display
332    /// (stable `revision` ⇒ GPU-buffer reuse, Task-1; its `source_handle` equals
333    /// the run's handle by the monotonic-handle reuse invariant). Leftovers in
334    /// `kept` — departed kernel solids AND the previous run's sketch sheets — are
335    /// dropped; `refresh_committed_sketches` (run in the shared continuation after)
336    /// re-adds the sheets, so dropping them here is correct.
337    ///
338    /// Then the report continuation (identical to the pre-seam run): keep the run's
339    /// resolved frames + solved sketch profiles and fold the per-feature timings /
340    /// output-names into the id-keyed report JSON, then hand it to
341    /// [`finish_apply`](Self::finish_apply) — the shared dirty/gizmo/overlay tail
342    /// that the parse-error branch in [`rerun_history`](Self::rerun_history) also
343    /// calls, so both paths share the continuation verbatim.
344    fn apply_run_output(&mut self, output: crate::pipeline::RunOutput) {
345        let crate::pipeline::RunOutput {
346            snapshot,
347            report,
348            provenance,
349            entity_origin,
350            assembly_poses,
351            assembly_fixed,
352            moved_solids,
353            imported_colors,
354        } = output;
355        // The runner already forced fresh displays for the solver-moved solids
356        // (their snapshot entries arrive `Some`); nothing extra to do main-side.
357        let _ = moved_solids;
358
359        // --- assembly pose-authority fold (build-spec §6 step 4 / §13) --------
360        // Adopt the solver's pose / isFixed write-backs into the owning ACOMP
361        // features by `inputParams.id` BEFORE anything persists or re-runs this
362        // document. Deliberately NOT `update_feature_params`: the fold must not
363        // mint an undo entry nor trigger a rerun (rerun → solve → fold → rerun
364        // would loop); `fold_param_no_undo` writes the param silently, and the
365        // next run's request simply carries the solved pose (a no-motion solve
366        // emits no updates, so fingerprints never churn).
367        for (id, pose) in assembly_poses {
368            self.history.fold_param_no_undo(&id, "transform", pose);
369        }
370        for (id, fixed) in assembly_fixed {
371            self.history
372                .fold_param_no_undo(&id, "isFixed", serde_json::Value::Bool(fixed));
373        }
374
375        // Adopt the run's eager provenance (SOLID last-writer) + entity origin
376        // (face/edge first-writer) wholesale (both drive `creating_feature` + the
377        // Info tab's `creatingFeature` with no cold re-run), and INVALIDATE the
378        // object-info measurement cache + any in-flight query: the geometry changed,
379        // so cached measurements are stale and a pending reply is superseded (a
380        // re-selection re-queries against the fresh geometry).
381        self.provenance = provenance.into_iter().collect();
382        self.entity_origin = entity_origin.into_iter().collect();
383        self.info_cache.clear();
384        self.pending_query.clear();
385
386        // Fold the run's IMPORTED COLOURS into the engine's own metadata store —
387        // the seam between the kernel's (thread-local, never persisted by us)
388        // name-keyed store and the one the Info window edits and the document
389        // saves. NON-overwriting on purpose: an import re-stamps its colour on
390        // every replay, and a colour the user changed in the panel must win.
391        for (name, hex) in imported_colors {
392            if self.metadata.attribute(&name, "color").is_none() {
393                self.metadata.set_attribute(&name, "color", &hex);
394            }
395        }
396
397        // Reconcile the scene: move current displays out, reinsert in order.
398        let mut kept: std::collections::HashMap<String, crate::scene::SolidDisplay> = self
399            .scene
400            .drain()
401            .into_iter()
402            .map(|solid| (solid.name.clone(), solid))
403            .collect();
404        for (name, _handle, maybe) in snapshot {
405            match maybe {
406                Some(display) => self.scene.insert_solid(display),
407                None => self
408                    .scene
409                    .insert_solid(kept.remove(&name).expect("keep target present")),
410            }
411        }
412
413        // Keep every plane frame the run resolved (DATUM/PLANE/SKETCH);
414        // `refresh_construction_datums` filters to the D/P producers.
415        self.construction_frames = report.frames.clone();
416        // Keep every solved sketch profile so `refresh_committed_sketches` can
417        // synthesize the committed sketch sheet solids.
418        self.sketch_profiles = report.profiles.clone();
419        // ...and every path chain, so a sketch whose geometry closes NO region (an
420        // open chain — the reported single line) still has something to draw.
421        self.sketch_paths = report.paths.clone();
422        // ...and every published point, so a sketch holding ONLY points (a
423        // hole-placement sketch) still has something to draw.
424        self.sketch_points = report.points.clone();
425        // Keep every axis line the run published so the angle gizmo can resolve a
426        // revolve `axis` reference to a world line without re-running.
427        self.sketch_axes = report.axes.clone();
428        // Fold the per-feature timing / output-name pairs into id-keyed maps so the
429        // history-tree UI can look them up by feature id.
430        let timings: serde_json::Map<String, serde_json::Value> = report
431            .feature_timings
432            .iter()
433            .map(|(id, ms)| (id.clone(), serde_json::json!(ms)))
434            .collect();
435        let outputs: serde_json::Map<String, serde_json::Value> = report
436            .feature_outputs
437            .iter()
438            .map(|(id, names)| (id.clone(), serde_json::json!(names)))
439            .collect();
440        let report_json = serde_json::json!({
441            "featureErrors": report.feature_errors,
442            "unresolved": report.unresolved,
443            "displayErrors": report.display_errors,
444            "featureTimings": timings,
445            "featureOutputs": outputs,
446        })
447        .to_string();
448        self.finish_apply(report_json);
449    }
450
451    /// The shared post-apply TAIL: mark dirty, store the report JSON, re-sync an
452    /// armed gizmo, and rebuild the persistent committed-sketch + construction-datum
453    /// overlays. Called after a real run's [`apply_run_output`](Self::apply_run_output)
454    /// AND from [`rerun_history`](Self::rerun_history)'s parse-error branch, so both
455    /// paths run the identical continuation. Callers read the result via
456    /// [`Self::history_report`](Self::history_report_json).
457    fn finish_apply(&mut self, report_json: String) {
458        self.dirty = true;
459        self.history_report = report_json;
460        // Keep an armed gizmo glued to its feature as the model rebuilds. During a
461        // transform drag the re-sync is driven by `transform_drag_to` itself (which
462        // resolves the delta against the frozen grab frame first, then syncs), so
463        // skip it here to avoid a redundant double-feed per drag frame. Transform
464        // mode re-feeds the widget frame; dimension mode re-projects the annotation
465        // leaders onto the rebuilt (param-changed) geometry.
466        if self.transform_gizmo.drag.is_none() {
467            match self.transform_gizmo.mode {
468                GizmoMode::Transform => self.sync_transform_gizmo(),
469                GizmoMode::Dimension => self.refresh_feature_dimension_overlay(),
470                GizmoMode::None => {}
471            }
472        }
473        // Keep an armed COMPONENT Move gizmo glued to its (possibly re-solved)
474        // component: re-anchor at the fresh member bbox. Never during its own
475        // drag — the drag feed owns the widget frame (free-move live-follow).
476        if self.component_move.drag.is_none() {
477            self.component_move_sync();
478        }
479        // Rebuild the persistent committed-sketch overlays against the reconciled
480        // scene (also covers `set_history_json`, which returns this call's result).
481        self.refresh_committed_sketches();
482        // Rebuild the persistent construction datum/plane overlays from the frames
483        // the run just surfaced (D/P features only; sketches render as curves).
484        self.refresh_construction_datums();
485        // Assembly documents: refresh the main-side session + component
486        // projection and fold the solved poses back into the document (the
487        // pose-authority contract — see `assembly_ops`). Componentless
488        // documents return immediately inside.
489        self.sync_assembly();
490        // Rebuild the assembly-constraint viewport overlays from the kernel
491        // session `sync_assembly` just (re)installed main-side (an inert no-op
492        // — empty group — for a document with no assembly state). ORDER
493        // MATTERS: the overlay read must follow the session install.
494        self.refresh_constraint_overlay();
495        // Re-derive every display colour from the metadata store, LAST — after
496        // the sketch sheets, the datum overlays and the assembly sync have all
497        // settled the scene, so no display inserted above is missed. This is why
498        // a model colour now survives a feature edit: the freshly tessellated
499        // display arrives colourless and is re-coloured from the store, instead
500        // of the colour living only on the display that was just thrown away.
501        // A no-op when nothing changed, which is the common case.
502        self.sync_colors_from_metadata();
503    }
504
505    /// Load a whole history document (a saved part file parses as one); the
506    /// engine now OWNS this recipe. Rolls to the last feature and builds it.
507    ///
508    /// The document's top-level `metadata` field (the Properties-panel
509    /// name-keyed store) is lifted out into [`Self::metadata`] before the feature
510    /// list is handed to the kernel — loading a part REPLACES the store wholesale
511    /// (a document with no `metadata` clears it), mirroring the previous metadata
512    /// manager's load semantics. Round-trips with [`Self::history_request_json`].
513    ///
514    /// The top-level `workbench` field (the ACTIVE-WORKBENCH id the save embedded
515    /// — see [`Self::history_request_json`]) is lifted off the kernel recipe the
516    /// same way and applied through [`Self::apply_settings_json`] — the SAME seam
517    /// the toolbar's workbench dropdown writes through — so the palette / context
518    /// offers / workbench buttons react to a restored workbench exactly as they
519    /// do to a manual switch (settings generation bump included). Tolerances:
520    ///
521    /// * a legacy document WITHOUT the field (or with a non-string value) leaves
522    ///   the current workbench untouched — opening an old file never yanks the
523    ///   user out of their workbench;
524    /// * an unknown/stale id is stored RAW (never an error): the settings layer
525    ///   deliberately doesn't validate ids, and every consumer resolves through
526    ///   the app-side registry's `resolve()`, which falls back to the default
527    ///   workbench — so a file saved by a build with a workbench this build
528    ///   doesn't know still opens cleanly;
529    /// * the restored id is deliberately NOT persisted to the settings blob —
530    ///   that blob stays the user's boot preference; a document's workbench is
531    ///   session-scoped (the next explicit dropdown change persists as usual).
532    pub fn set_history_json(&mut self, request_json: &str) -> Result<String, String> {
533        // A document switch is a wholesale model replacement: drop the incremental
534        // cache so the new model starts from a clean slate (no cross-document
535        // staleness, no unbounded cache growth across many opens). The roll/edit
536        // hot path (`rerun_history`) deliberately KEEPS the cache for instant
537        // rollback; this is the ONE place the full clear belongs.
538        brep_kernel::clear_history_cache();
539        // Reset the delta runner's baseline in lockstep with the cache clear so the
540        // new document is a FULL rebuild (no reuse against the prior model's names).
541        self.runner.reset();
542        self.pending_mesh_imports.clear();
543        self.mesh_preview_results.clear();
544        // A probed-but-unconsumed STEP assembly belongs to the document being
545        // replaced: importing it into the NEW one would land a file the user never
546        // chose here (and hold its solids resident until they did). A probe
547        // still running is likewise the old document's: its answer is dropped.
548        self.pending_step_assembly = None;
549        self.pending_step_probes.clear();
550        self.step_probe_results.clear();
551        let mut document: serde_json::Value = serde_json::from_str(request_json)
552            .map_err(|error| format!("history parse: {error}"))?;
553        // Pull `metadata` out of the document so the engine holds the single copy
554        // (kept off the History recipe the kernel executes).
555        let metadata_value = document
556            .as_object_mut()
557            .and_then(|object| object.remove("metadata"));
558        self.metadata.load_json(metadata_value.as_ref());
559        // Lift the saved active-workbench id off the kernel recipe (`metadata`'s
560        // sibling — the kernel would ignore the extra field, but the engine owns
561        // it) and apply it through the shared settings seam; see the doc comment
562        // for the legacy/unknown-id tolerances. Applied BEFORE the rebuild below
563        // so anything reading the settings post-run already sees the restored id.
564        if let Some(workbench_id) = document
565            .as_object_mut()
566            .and_then(|object| object.remove("workbench"))
567            .as_ref()
568            .and_then(serde_json::Value::as_str)
569        {
570            let _ = self.apply_settings_json(
571                &serde_json::json!({ "workbench": workbench_id }).to_string(),
572            );
573        }
574        // Stamp each sketch's per-loop ids onto its geometries before the model
575        // is built. Deriving already yields the right ids, so this renames
576        // nothing — it PERSISTS them, which is what lets a loop keep its identity
577        // when the edge the id came from is deleted. Doing it here (rather than
578        // only on sketch commit) covers every document, including one built by a
579        // script that never enters sketch mode. See the kernel's
580        // `features/sketch/loop_ids`.
581        stamp_sketch_loop_ids(&mut document);
582        // SEED this thread's kernel parts library from the document's block.
583        // The per-run request no longer carries the block (see
584        // `History::parts_library`), so this explicit install is the ONE door
585        // that seeds a loaded document — it is what `parts_library_json()`
586        // (SAVE), the main-side `sync_assembly` re-run and the export lanes all
587        // resolve ACOMPs against, and it bumps the revision so the next run
588        // hands the fresh library to the background runner.
589        let library = document
590            .get("partsLibrary")
591            .and_then(|block| serde_json::from_value(block.clone()).ok())
592            .unwrap_or_default();
593        brep_kernel::install_parts_library(&library);
594        self.history = History::from_request_json(&document.to_string())?;
595        Ok(self.rerun_history())
596    }
597
598    /// The whole history request document (persistence / debugging), with the
599    /// engine-owned extras folded back in on top of the kernel recipe so
600    /// save→open round-trips them:
601    ///
602    /// * `metadata` — the Properties-panel store, written only when non-empty so
603    ///   an un-annotated model persists as before;
604    /// * `workbench` — the CURRENT active-workbench id
605    ///   (`self.settings.workbench`), ALWAYS written so a saved part reopens in
606    ///   the workbench it was saved from (restored by
607    ///   [`Self::set_history_json`]). Always-embed keeps the invariant simple:
608    ///   the serialized field tracks the LIVE setting, never a stale stored copy
609    ///   — the load lifts it off the kernel recipe entirely, so this is the ONE
610    ///   place it is (re)written. Note the deliberate consequence: switching
611    ///   workbench changes this document, so the file panel's dirty flag flips —
612    ///   consistent with the `metadata` precedent, and semantically true now
613    ///   that the workbench is part of the saved file.
614    pub fn history_request_json(&self) -> String {
615        let mut document: serde_json::Value =
616            serde_json::from_str(&self.history.request_json())
617                .unwrap_or_else(|_| serde_json::json!({}));
618        if let Some(object) = document.as_object_mut() {
619            if !self.metadata.is_empty() {
620                object.insert("metadata".into(), self.metadata.to_json());
621            }
622            object.insert(
623                "workbench".into(),
624                serde_json::Value::String(self.settings.workbench.clone()),
625            );
626        }
627        document.to_string()
628    }
629
630    /// The tree listing `{ step, features:[{index,type,id}] }` for the UI panel.
631    pub fn history_listing_json(&self) -> String {
632        self.history.listing_json()
633    }
634
635    /// The last build report JSON.
636    pub fn history_report_json(&self) -> String {
637        self.history_report.clone()
638    }
639
640    pub fn history_len(&self) -> usize {
641        self.history.len()
642    }
643
644    /// The rolled-to (selected) feature index.
645    pub fn history_rollback(&self) -> usize {
646        self.history.rollback()
647    }
648
649    pub fn feature_type_at(&self, index: usize) -> Option<String> {
650        self.history.feature_type(index)
651    }
652
653    pub fn feature_id_at(&self, index: usize) -> Option<String> {
654        self.history.feature_id(index)
655    }
656
657    /// The `inputParams` document of feature `index` (`"null"` if none) — the
658    /// dialog's editing-buffer source.
659    pub fn feature_params_json(&self, index: usize) -> String {
660        self.history
661            .feature_params(index)
662            .map(|v| v.to_string())
663            .unwrap_or_else(|| "null".to_string())
664    }
665
666    /// Mint the id for a NEW feature: `{base}{N}` where `base` is the feature's
667    /// shortName ([`crate::features::feature_short_name`]) and `N` is the part
668    /// history's persistent GLOBAL counter (monotonic, never reused, round-trips
669    /// save/load — see [`History::next_feature_id`]). `&mut` because the counter
670    /// advances; if the caller's `add_feature` then fails the number is simply
671    /// skipped (monotonic-with-gaps is the contract, not an error).
672    pub fn next_feature_id(&mut self, base: &str) -> String {
673        self.history.next_feature_id(base)
674    }
675
676    /// Roll the model to feature `index`: re-run `features[0..=index]`.
677    pub fn roll_to(&mut self, index: usize) -> String {
678        self.history.set_rollback(index);
679        self.rerun_history()
680    }
681
682    /// Replace feature `id`'s input params and re-run at the current rollback →
683    /// the viewport updates live.
684    pub fn update_feature_params(
685        &mut self,
686        id: &str,
687        input_params_json: &str,
688    ) -> Result<String, String> {
689        let params: serde_json::Value = serde_json::from_str(input_params_json)
690            .map_err(|e| format!("feature params parse: {e}"))?;
691        let index = self
692            .history
693            .index_of(id)
694            .ok_or_else(|| format!("no feature with id '{id}'"))?;
695        self.history.set_feature_params(index, params);
696        Ok(self.rerun_history())
697    }
698
699    /// Replace the `inputParams` of MANY features as ONE model edit — the
700    /// [`Self::update_feature_params`] batch sibling ([`Self::add_features`] is
701    /// the append-only one). ONE undo checkpoint and ONE history re-run for the
702    /// whole set.
703    ///
704    /// The lane that needs it: a PACKED BOM row rolls up every occurrence whose
705    /// occurrence data matches, so editing one of its cells writes the same key
706    /// into N ACOMP features. Looping `update_feature_params` would cost N
707    /// re-runs and — worse — N undo entries, so taking back one visible edit
708    /// would need N presses of undo.
709    ///
710    /// Unknown ids are reported (the whole batch is refused before anything is
711    /// written, so a typo can never half-apply); an empty batch is a no-op that
712    /// neither checkpoints nor runs.
713    pub fn update_many_feature_params(
714        &mut self,
715        edits: &[(String, serde_json::Value)],
716    ) -> Result<String, String> {
717        if edits.is_empty() {
718            return Ok(self.history_report.clone());
719        }
720        let mut resolved: Vec<(usize, serde_json::Value)> = Vec::with_capacity(edits.len());
721        for (id, params) in edits {
722            let index = self
723                .history
724                .index_of(id)
725                .ok_or_else(|| format!("no feature with id '{id}'"))?;
726            resolved.push((index, params.clone()));
727        }
728        self.history.set_many_feature_params(&resolved);
729        Ok(self.rerun_history())
730    }
731
732    /// Append a feature (a full `{type, inputParams, …}` descriptor) and roll to
733    /// it. The caller assigns a unique `id` (see [`Self::next_feature_id`]).
734    pub fn add_feature(&mut self, feature_json: &str) -> Result<String, String> {
735        let feature: serde_json::Value =
736            serde_json::from_str(feature_json).map_err(|e| format!("feature parse: {e}"))?;
737        self.history.push_feature(feature);
738        let last = self.history.len().saturating_sub(1);
739        self.history.set_rollback(last);
740        Ok(self.rerun_history())
741    }
742
743    /// Append MANY features and roll to the last — [`Self::add_feature`]'s batch
744    /// sibling, and the reason it exists: `add_feature` re-runs the WHOLE history
745    /// per call, so a lane that appends N features by looping it costs N rebuilds
746    /// (O(N²) work on an N-part STEP-assembly import). This pushes all of them,
747    /// then re-runs ONCE — one rebuild, one undo checkpoint, one
748    /// [`applied_generation`](Self::applied_generation) bump.
749    ///
750    /// Deliberately NOT [`Self::set_history_json`]: that is the document-SWITCH
751    /// path (it clears the kernel history cache and resets the runner's delta
752    /// baseline, forcing a full cold rebuild), which is the wrong mechanism for an
753    /// append onto the live document.
754    ///
755    /// An empty batch is a no-op — no checkpoint, no run, no generation bump —
756    /// and returns the standing report. The caller assigns each feature's unique
757    /// `id` (see [`Self::next_feature_id`]).
758    pub fn add_features(&mut self, features: &[serde_json::Value]) -> String {
759        if features.is_empty() {
760            return self.history_report.clone();
761        }
762        self.history.push_features(features.to_vec());
763        let last = self.history.len().saturating_sub(1);
764        self.history.set_rollback(last);
765        self.rerun_history()
766    }
767
768    /// Delete the feature with id `id` (no-op if absent) and re-run, clamping the
769    /// rolled-to step.
770    pub fn delete_feature(&mut self, id: &str) -> String {
771        if let Some(index) = self.history.index_of(id) {
772            self.history.remove_feature(index);
773            let step = self
774                .history
775                .rollback()
776                .min(self.history.len().saturating_sub(1));
777            self.history.set_rollback(step);
778        }
779        self.rerun_history()
780    }
781
782    /// Move feature `index` one slot up/down (reorder), keeping it selected.
783    pub fn reorder_feature(&mut self, index: usize, up: bool) -> String {
784        let len = self.history.len();
785        if len >= 2 {
786            let target = if up {
787                index.checked_sub(1)
788            } else if index + 1 < len {
789                Some(index + 1)
790            } else {
791                None
792            };
793            if let Some(target) = target {
794                self.history.swap(index, target);
795                self.history.set_rollback(target);
796            }
797        }
798        self.rerun_history()
799    }
800
801}
802
803impl EngineState {
804    /// Whether an undo step is available (to enable the toolbar's Undo button).
805    pub fn can_undo(&self) -> bool {
806        self.history.can_undo()
807    }
808
809    /// Whether a redo step is available.
810    pub fn can_redo(&self) -> bool {
811        self.history.can_redo()
812    }
813
814    /// Undo the last model mutation: restore the previous document + rolled-to
815    /// step, then re-run + reconcile the scene. Returns the build report; a no-op
816    /// (empty undo stack) returns the last report unchanged.
817    pub fn undo(&mut self) -> String {
818        if self.history.undo() {
819            self.reinstall_rewound_parts_library();
820            self.rerun_history()
821        } else {
822            self.history_report.clone()
823        }
824    }
825
826    /// Redo the last undone model mutation (symmetric with [`Self::undo`]).
827    pub fn redo(&mut self) -> String {
828        if self.history.redo() {
829            self.reinstall_rewound_parts_library();
830            self.rerun_history()
831        } else {
832            self.history_report.clone()
833        }
834    }
835
836    /// Push the just-rewound `partsLibrary` block back into the kernel's
837    /// main-side store, so time travel moves the LIBRARY with the document.
838    ///
839    /// Without this, undo only half-works on anything that edits a library
840    /// entry. The undo snapshot carries the block (`History::Snapshot`), but the
841    /// block is a MIRROR: the per-run request does not carry it
842    /// ([`History::prefix_request`] omits it) and `sync_assembly` re-serializes
843    /// the kernel store back over it after every run. So a rewound block that
844    /// was never pushed into the store is simply overwritten again, and the
845    /// undo silently does nothing — which is what
846    /// [`Self::set_part_attribute`](crate::engine_state::EngineState::set_part_attribute)
847    /// would hit on its first undo.
848    ///
849    /// `install_parts_library` matches the store to the block BY CONTENT
850    /// IDENTITY: an identical entry is kept verbatim (so a heal this side
851    /// derived is not clobbered), a changed one is replaced and marked dirty
852    /// (so the ACOMP self-heal re-derives every instance), and one absent from
853    /// the block is dropped. An unchanged block is therefore a no-op, which is
854    /// the overwhelmingly common undo.
855    fn reinstall_rewound_parts_library(&mut self) {
856        let library = serde_json::from_value(self.history.parts_library().clone())
857            .unwrap_or_default();
858        brep_kernel::install_parts_library(&library);
859    }
860
861    // --- Selection (Esc clears / viewport click selects) ------------------
862
863}
864
865/// Stamp per-loop ids onto every SKETCH feature's geometries in a history
866/// document, in place.
867///
868/// The persistence half of per-loop face naming. The kernel DERIVES a loop's id
869/// the same way every run, so this renames nothing; what it adds is durability —
870/// a stored id survives deleting the edge it was originally derived from, which
871/// derivation alone cannot. Running it on document LOAD (not only on sketch
872/// commit) means a model built by a script, an import, or any other path that
873/// never opens the sketch editor still gets its ids written down.
874///
875/// Assemblies: a parts-library entry embeds a FULL sub-part history
876/// (`partsLibrary[*].document`), whose features can include sketches of its own,
877/// so the walk recurses into each one. Without that, a sketch inside an imported
878/// assembly part would derive correct names but carry no stored ids — exactly the
879/// case (deleting the edge an id came from) that persisting exists to cover.
880/// Depth is bounded: a sub-document's own library is seeded from the kernel store
881/// rather than nested inside the entry, so one level of recursion reaches all of
882/// them, and `MAX_LIBRARY_DEPTH` stops a malformed self-referential document.
883///
884/// Malformed features are skipped rather than rejected: this is a best-effort
885/// enrichment on the way to the kernel, which validates the document itself.
886fn stamp_sketch_loop_ids(document: &mut serde_json::Value) {
887    /// Depth cap for the embedded sub-document walk — a guard against a
888    /// hand-edited or corrupt document that nests libraries into each other.
889    const MAX_LIBRARY_DEPTH: usize = 8;
890    stamp_sketch_loop_ids_to_depth(document, MAX_LIBRARY_DEPTH);
891}
892
893/// [`stamp_sketch_loop_ids`] with the remaining recursion budget.
894fn stamp_sketch_loop_ids_to_depth(document: &mut serde_json::Value, depth: usize) {
895    if let Some(features) = document
896        .get_mut("features")
897        .and_then(serde_json::Value::as_array_mut)
898    {
899        for feature in features {
900            if feature.get("type").and_then(serde_json::Value::as_str) != Some("S") {
901                continue;
902            }
903            let Some(sketch) = feature
904                .get_mut("persistentData")
905                .and_then(|data| data.get_mut("sketch"))
906            else {
907                continue;
908            };
909            brep_kernel::assign_sketch_loop_ids(sketch);
910        }
911    }
912    if depth == 0 {
913        return;
914    }
915    // Each library entry's embedded sub-part history gets the same treatment.
916    let Some(library) = document
917        .get_mut("partsLibrary")
918        .and_then(serde_json::Value::as_object_mut)
919    else {
920        return;
921    };
922    for (_, entry) in library.iter_mut() {
923        let Some(embedded) = entry.get_mut("document") else {
924            continue;
925        };
926        stamp_sketch_loop_ids_to_depth(embedded, depth - 1);
927    }
928}
929
930// ============================================================================
931// File-management convenience (appended — see the model/file-mgmt slice).
932// Kept as a SEPARATE `impl` block so concurrent edits to the primary block do
933// not conflict; purely additive over the existing history API.
934// ============================================================================
935impl EngineState {
936    /// Load a whole model document (a saved `.BREP.json` recipe) and FRAME it:
937    /// [`set_history_json`](Self::set_history_json) (which rolls to the last
938    /// feature) followed by [`zoom_to_fit`](Self::zoom_to_fit). The one call the
939    /// file panel's **Open** needs — the model IS the engine-owned history, so
940    /// opening a file is loading its request JSON and reframing. Returns the
941    /// build-report JSON.
942    pub fn load_model_and_fit(&mut self, request_json: &str) -> Result<String, String> {
943        // Frame once the run lands, not now: under a background runner (native
944        // thread / wasm worker) the freshly loaded model is not resident yet when
945        // `set_history_json` returns, so an immediate `zoom_to_fit` would frame the
946        // OLD scene. Set BEFORE the submit so the Inline runner's in-call `pump`
947        // still frames synchronously. See [`EngineState::pending_fit`].
948        self.pending_fit = true;
949        let result = self.set_history_json(request_json);
950        if result.is_err() {
951            // A rejected document submits no run, so the armed fit would otherwise
952            // fire on the OLD scene next frame — disarm it.
953            self.pending_fit = false;
954        }
955        result
956    }
957}
958
959
960// ===========================================================================
961// Per-loop face naming: a document gets its sketch loop ids on LOAD.
962// ===========================================================================
963//
964// Deriving already yields the right ids, so loading renames nothing; what the
965// stamp adds is durability. A model built by a script or an import never opens
966// the sketch editor, so without this it would carry no stored ids and could not
967// survive deleting the edge an id was derived from.
968#[cfg(test)]
969mod sketch_loop_id_stamp_tests {
970    use super::*;
971
972    /// A two-loop sketch (gids 10-13 and 20-23) extruded — no sketch-mode visit.
973    fn two_loop_history(geometries: serde_json::Value) -> String {
974        serde_json::json!({
975            "expressions": "",
976            "configurator": {},
977            "features": [
978                {
979                    "type": "S",
980                    "inputParams": { "id": "Sk" },
981                    "persistentData": {
982                        "basis": { "origin":[0,0,0], "x":[1,0,0], "y":[0,1,0], "z":[0,0,1] },
983                        "sketch": {
984                            "points": [
985                                { "id": 1, "x": 0.0, "y": 0.0, "fixed": true },
986                                { "id": 2, "x": 1.0, "y": 0.0, "fixed": true },
987                                { "id": 3, "x": 1.0, "y": 1.0, "fixed": true },
988                                { "id": 4, "x": 0.0, "y": 1.0, "fixed": true },
989                                { "id": 5, "x": 3.0, "y": 0.0, "fixed": true },
990                                { "id": 6, "x": 4.0, "y": 0.0, "fixed": true },
991                                { "id": 7, "x": 4.0, "y": 1.0, "fixed": true },
992                                { "id": 8, "x": 3.0, "y": 1.0, "fixed": true }
993                            ],
994                            "geometries": geometries,
995                            "constraints": []
996                        }
997                    }
998                },
999                {
1000                    "type": "E",
1001                    "inputParams": {
1002                        "id": "Ext", "profile": "Sk", "distance": 1.0,
1003                        "boolean": { "targets": [], "operation": "NONE" }
1004                    },
1005                    "persistentData": {}
1006                }
1007            ]
1008        })
1009        .to_string()
1010    }
1011
1012    fn line(id: i64, a: i64, b: i64) -> serde_json::Value {
1013        serde_json::json!({ "id": id, "type": "line", "points": [a, b] })
1014    }
1015
1016    /// Both loops, with the FIRST loop's anchor edge (gid 10) present.
1017    fn both_loops() -> serde_json::Value {
1018        serde_json::json!([
1019            line(10, 1, 2), line(11, 2, 3), line(12, 3, 4), line(13, 4, 1),
1020            line(20, 5, 6), line(21, 6, 7), line(22, 7, 8), line(23, 8, 5),
1021        ])
1022    }
1023
1024    /// The stored `loopId` of each geometry, in document order.
1025    fn stored_ids(engine: &EngineState) -> Vec<(i64, Option<i64>)> {
1026        let document: serde_json::Value =
1027            serde_json::from_str(&engine.history_request_json()).expect("document");
1028        document["features"][0]["persistentData"]["sketch"]["geometries"]
1029            .as_array()
1030            .expect("geometries")
1031            .iter()
1032            .map(|geometry| {
1033                (
1034                    geometry["id"].as_i64().expect("gid"),
1035                    geometry.get("loopId").and_then(serde_json::Value::as_i64),
1036                )
1037            })
1038            .collect()
1039    }
1040
1041    /// Loading a scripted document stamps every loop's id onto ALL of its edges,
1042    /// and the stamp round-trips back out through `history_request_json` (so a
1043    /// save after a plain open persists it).
1044    #[test]
1045    fn loading_a_scripted_history_stamps_and_persists_loop_ids() {
1046        let mut engine = EngineState::new();
1047        engine
1048            .set_history_json(&two_loop_history(both_loops()))
1049            .expect("history loads");
1050        assert_eq!(
1051            stored_ids(&engine),
1052            vec![
1053                (10, Some(10)), (11, Some(10)), (12, Some(10)), (13, Some(10)),
1054                (20, Some(20)), (21, Some(20)), (22, Some(20)), (23, Some(20)),
1055            ],
1056        );
1057    }
1058
1059    /// Assemblies: a sketch inside a parts-library entry's EMBEDDED sub-part
1060    /// history gets stamped too. That document never passes through the sketch
1061    /// editor (it arrives whole, from a STEP assembly import), so the load-time
1062    /// stamp is its only chance to get durable ids.
1063    #[test]
1064    fn a_sketch_inside_an_embedded_part_document_is_stamped() {
1065        let part_document = serde_json::json!({
1066            "expressions": "",
1067            "configurator": {},
1068            "features": [
1069                {
1070                    "type": "S",
1071                    "inputParams": { "id": "SubSk" },
1072                    "persistentData": {
1073                        "basis": { "origin":[0,0,0], "x":[1,0,0], "y":[0,1,0], "z":[0,0,1] },
1074                        "sketch": {
1075                            "points": [
1076                                { "id": 1, "x": 0.0, "y": 0.0, "fixed": true },
1077                                { "id": 2, "x": 1.0, "y": 0.0, "fixed": true },
1078                                { "id": 3, "x": 1.0, "y": 1.0, "fixed": true },
1079                                { "id": 4, "x": 0.0, "y": 1.0, "fixed": true }
1080                            ],
1081                            "geometries": [
1082                                line(40, 1, 2), line(41, 2, 3), line(42, 3, 4), line(43, 4, 1)
1083                            ],
1084                            "constraints": []
1085                        }
1086                    }
1087                }
1088            ]
1089        });
1090        let mut document = serde_json::json!({
1091            "expressions": "",
1092            "configurator": {},
1093            "features": [],
1094            "partsLibrary": {
1095                "Part-1": {
1096                    "sourceKey": "k",
1097                    "sourceSignature": "s",
1098                    "document": part_document,
1099                }
1100            }
1101        });
1102        stamp_sketch_loop_ids(&mut document);
1103
1104        let stamped: Vec<Option<i64>> = document["partsLibrary"]["Part-1"]["document"]
1105            ["features"][0]["persistentData"]["sketch"]["geometries"]
1106            .as_array()
1107            .expect("embedded geometries")
1108            .iter()
1109            .map(|geometry| geometry.get("loopId").and_then(serde_json::Value::as_i64))
1110            .collect();
1111        assert_eq!(
1112            stamped,
1113            vec![Some(40), Some(40), Some(40), Some(40)],
1114            "the embedded part's loop got its id on every edge",
1115        );
1116    }
1117
1118    /// A self-referential document terminates instead of recursing forever.
1119    #[test]
1120    fn a_self_nesting_library_terminates() {
1121        let mut document = serde_json::json!({ "features": [], "partsLibrary": {} });
1122        // Nest a library inside a library, deeper than the depth cap.
1123        let mut inner = serde_json::json!({ "features": [] });
1124        for _ in 0..20 {
1125            inner = serde_json::json!({
1126                "features": [],
1127                "partsLibrary": { "P": { "document": inner } }
1128            });
1129        }
1130        document["partsLibrary"]["P"] = serde_json::json!({ "document": inner });
1131        stamp_sketch_loop_ids(&mut document); // must return, not hang or overflow
1132    }
1133
1134    /// THE POINT of persisting: delete the edge a loop's id was derived from and
1135    /// the loop keeps its identity, so the cap face built from it keeps its name.
1136    /// Derivation alone could not do this — the anchor gid is gone.
1137    #[test]
1138    fn a_loaded_document_survives_deleting_the_anchor_edge() {
1139        let mut engine = EngineState::new();
1140        engine
1141            .set_history_json(&two_loop_history(both_loops()))
1142            .expect("history loads");
1143        // Save as the app would: the stamped ids are now in the document.
1144        let saved = engine.history_request_json();
1145
1146        // Edit that saved document the way the editor would: drop loop A's anchor
1147        // (gid 10) and close the loop with a NEW edge (gid 30, a fresh id above
1148        // everything). The surviving edges still carry `loopId: 10`.
1149        let mut edited: serde_json::Value = serde_json::from_str(&saved).expect("saved document");
1150        let geometries = edited["features"][0]["persistentData"]["sketch"]["geometries"]
1151            .as_array_mut()
1152            .expect("geometries");
1153        geometries.retain(|geometry| geometry["id"].as_i64() != Some(10));
1154        geometries.push(line(30, 1, 2));
1155
1156        let mut reopened = EngineState::new();
1157        reopened
1158            .set_history_json(&edited.to_string())
1159            .expect("edited history loads");
1160        let ids = stored_ids(&reopened);
1161        assert!(
1162            ids.iter().all(|(_, loop_id)| matches!(
1163                loop_id,
1164                Some(10) | Some(20)
1165            )),
1166            "both loops kept their ids after the anchor edge was deleted: {ids:?}",
1167        );
1168        // The NEW edge joined loop A rather than seeding an id of its own.
1169        assert_eq!(
1170            ids.iter().find(|(gid, _)| *gid == 30).map(|(_, id)| *id),
1171            Some(Some(10)),
1172            "the replacement edge inherited loop A's id: {ids:?}",
1173        );
1174    }
1175}
1176
1177// ===========================================================================
1178// Instant history-rollback: the incremental cache must survive a roll/edit.
1179// ===========================================================================
1180//
1181// Regression guard for the fix that made roll-to-step / edit INSTANT: the engine
1182// no longer sledgehammers `clear_history_cache()` before every rerun, so an
1183// unchanged upstream prefix (a heavy STEP import + primitives) replays from the
1184// kernel's incremental cache (`reused`, timing 0.0) instead of cold re-executing.
1185// The correctness worry the sledgehammer guarded — "roll BEFORE a boolean that
1186// consumed its target displays the freed/re-used handle" — cannot occur: a
1187// boolean clones its inputs and frees only its own intermediates; a consumed
1188// input handle is freed exactly when its PRODUCING cache entry is invalidated
1189// (never by a downstream consumer). So rolling to the box shows the box.
1190#[cfg(test)]
1191mod history_cache_rollback_tests {
1192    use super::*;
1193    use crate::scene::SolidDisplay;
1194
1195    /// F1 = box `Box` (side 20 → volume 8000), F2 = cylinder `Pin`, F3 = boolean
1196    /// `Cut` = SUBTRACT(target=Box, tools=[Pin]). The boolean CONSUMES Box (its
1197    /// target) and Pin, removing both names and adding one subtracted solid.
1198    fn box_pin_cut_history() -> String {
1199        serde_json::json!({
1200            "expressions": "",
1201            "configurator": {},
1202            "features": [
1203                {
1204                    "type": "P.CU",
1205                    "inputParams": {
1206                        "id": "Box",
1207                        "sizeX": 20.0, "sizeY": 20.0, "sizeZ": 20.0,
1208                        "transform": {
1209                            "position": [0.0, 0.0, 0.0],
1210                            "rotationEuler": [0.0, 0.0, 0.0],
1211                            "scale": [1.0, 1.0, 1.0]
1212                        },
1213                        "boolean": { "targets": [], "operation": "NONE" }
1214                    },
1215                    "persistentData": {}
1216                },
1217                {
1218                    "type": "P.CY",
1219                    "inputParams": {
1220                        "id": "Pin",
1221                        "radius": 6.0, "height": 30.0,
1222                        "transform": {
1223                            "position": [10.0, -5.0, 10.0],
1224                            "rotationEuler": [0.0, 0.0, 0.0],
1225                            "scale": [1.0, 1.0, 1.0]
1226                        },
1227                        "boolean": { "targets": [], "operation": "NONE" }
1228                    },
1229                    "persistentData": {}
1230                },
1231                {
1232                    "type": "B",
1233                    "inputParams": {
1234                        "id": "Cut",
1235                        "targetSolid": "Box",
1236                        "boolean": { "operation": "SUBTRACT", "targets": ["Pin"] }
1237                    },
1238                    "persistentData": {}
1239                }
1240            ]
1241        })
1242        .to_string()
1243    }
1244
1245    /// Closed-mesh volume via the divergence theorem: V = (1/6) Σ p0·(p1×p2) over
1246    /// triangles — the volume of the geometry the USER actually sees on screen.
1247    fn display_volume(solid: &SolidDisplay) -> f64 {
1248        let p = &solid.mesh.positions;
1249        let mut v = 0.0f64;
1250        for tri in solid.mesh.indices.chunks_exact(3) {
1251            let a = p[tri[0] as usize];
1252            let b = p[tri[1] as usize];
1253            let c = p[tri[2] as usize];
1254            let (a, b, c) = (
1255                [a[0] as f64, a[1] as f64, a[2] as f64],
1256                [b[0] as f64, b[1] as f64, b[2] as f64],
1257                [c[0] as f64, c[1] as f64, c[2] as f64],
1258            );
1259            // p0 · (p1 × p2)
1260            let cross = [
1261                b[1] * c[2] - b[2] * c[1],
1262                b[2] * c[0] - b[0] * c[2],
1263                b[0] * c[1] - b[1] * c[0],
1264            ];
1265            v += a[0] * cross[0] + a[1] * cross[1] + a[2] * cross[2];
1266        }
1267        (v / 6.0).abs()
1268    }
1269
1270    /// The timing (ms) the last build reported for feature `id`. A REPLAYED
1271    /// (cached) feature reports EXACTLY 0.0; a re-executed one reports its real
1272    /// wall-clock (> 0.0 for any real geometry op).
1273    fn timing(report: &serde_json::Value, id: &str) -> f64 {
1274        report["featureTimings"][id]
1275            .as_f64()
1276            .unwrap_or_else(|| panic!("no timing for '{id}' in {report}"))
1277    }
1278
1279    /// THE decisive test: rolling to the box BEFORE the boolean that consumed it
1280    /// (a) shows the box's geometry (right volume) and (b) replays it from the
1281    /// incremental cache (timing 0.0), i.e. instantly — not a cold re-execution.
1282    #[test]
1283    fn roll_to_step_before_boolean_is_correct_and_cached() {
1284        brep_kernel::clear_history_cache(); // hermetic start (shared thread-local cache)
1285        let mut engine = EngineState::new();
1286        engine.set_history_json(&box_pin_cut_history()).unwrap();
1287
1288        // Full run: the boolean produced ONE subtracted solid, smaller than the box.
1289        assert_eq!(engine.scene.solids().len(), 1);
1290        let cut_vol = display_volume(&engine.scene.solids()[0]);
1291        assert!(
1292            cut_vol < 8000.0 - 1.0,
1293            "subtracted solid ({cut_vol}) must be smaller than the 8000 box"
1294        );
1295
1296        // Roll to F1 (the box) — the step BEFORE the boolean.
1297        let report: serde_json::Value =
1298            serde_json::from_str(&engine.roll_to(0)).unwrap();
1299
1300        // CORRECTNESS: exactly the box is displayed, full volume 8000 — the cached
1301        // replay shows the box's own geometry, NOT a freed/re-used handle.
1302        assert_eq!(engine.scene.solids().len(), 1, "only the box after rollback");
1303        let box_solid = engine
1304            .scene
1305            .solid("Box")
1306            .expect("box resident after rolling back before the boolean");
1307        let box_vol = display_volume(box_solid);
1308        assert!(
1309            (box_vol - 8000.0).abs() < 1.0,
1310            "rolled-back box volume {box_vol} != 8000 (stale/freed handle?)"
1311        );
1312
1313        // INSTANT: the box replayed from the incremental cache (timing 0.0), not a
1314        // cold re-execution. THIS is what the sledgehammer removal buys.
1315        assert_eq!(
1316            timing(&report, "Box"),
1317            0.0,
1318            "rolled-back box must replay from cache (timing 0.0), not re-execute"
1319        );
1320    }
1321
1322    /// Rolling to the box then FORWARD to the boolean again restores the correct
1323    /// subtracted geometry AND replays the entire prefix from cache (all timings
1324    /// 0.0) — nothing was invalidated by the round-trip, so the roll-forward is
1325    /// instant too. Crucially the redisplayed "Box" is the SUBTRACTED result (the
1326    /// name is re-bound from the cube's handle to the boolean's), NOT the stale
1327    /// full box left over from the rollback — the handle-gated display reuse
1328    /// re-tessellates it.
1329    #[test]
1330    fn roll_forward_after_rollback_restores_geometry_and_replays_all() {
1331        brep_kernel::clear_history_cache();
1332        let mut engine = EngineState::new();
1333        engine.set_history_json(&box_pin_cut_history()).unwrap();
1334
1335        engine.roll_to(0); // back to the box
1336        let rolled = engine.scene.solid("Box").expect("box after rollback");
1337        assert!(
1338            (display_volume(rolled) - 8000.0).abs() < 1.0,
1339            "rolled-back name 'Box' shows the full box"
1340        );
1341
1342        let report: serde_json::Value =
1343            serde_json::from_str(&engine.roll_to(2)).unwrap();
1344        // The subtracted solid is back — the name "Box" now shows the boolean
1345        // result (smaller than the full box), not the stale rollback display.
1346        assert_eq!(engine.scene.solids().len(), 1);
1347        let vol = display_volume(engine.scene.solid("Box").expect("boolean result"));
1348        assert!(
1349            vol < 8000.0 - 1.0,
1350            "boolean result restored under name 'Box' ({vol}), not the stale 8000 box"
1351        );
1352        // The WHOLE prefix replayed from cache — the round-trip invalidated nothing.
1353        assert_eq!(timing(&report, "Box"), 0.0, "box replayed on roll-forward");
1354        assert_eq!(timing(&report, "Pin"), 0.0, "pin replayed on roll-forward");
1355        assert_eq!(timing(&report, "Cut"), 0.0, "boolean replayed on roll-forward");
1356    }
1357
1358    /// Editing ONLY the boolean keeps the upstream box + pin cached (timing 0.0);
1359    /// just the boolean re-executes — the incremental-dependency win.
1360    #[test]
1361    fn editing_boolean_keeps_upstream_cached() {
1362        brep_kernel::clear_history_cache();
1363        let mut engine = EngineState::new();
1364        engine.set_history_json(&box_pin_cut_history()).unwrap();
1365
1366        // Flip the cut to a UNION (fingerprint + geometry both change).
1367        let new_params = serde_json::json!({
1368            "id": "Cut",
1369            "targetSolid": "Box",
1370            "boolean": { "operation": "UNION", "targets": ["Pin"] }
1371        })
1372        .to_string();
1373        let report: serde_json::Value =
1374            serde_json::from_str(&engine.update_feature_params("Cut", &new_params).unwrap())
1375                .unwrap();
1376
1377        assert_eq!(
1378            timing(&report, "Box"),
1379            0.0,
1380            "box stayed cached across a boolean edit"
1381        );
1382        assert_eq!(
1383            timing(&report, "Pin"),
1384            0.0,
1385            "pin stayed cached across a boolean edit"
1386        );
1387        assert!(
1388            timing(&report, "Cut") > 0.0,
1389            "the edited boolean re-executed"
1390        );
1391    }
1392
1393    /// STEP-1 regression — a DATUM/FACE-attached sketch's committed sheet must land
1394    /// EXACTLY where the live overlay is drawn. The bug: `enter_sketch_mode` built
1395    /// the live session plane from the persisted `basis` ALONE, while the kernel
1396    /// materializes the sheet against the frame it resolves LIVE from the
1397    /// `sketchPlane` reference (here a datum lifted to z=5). A stale/identity basis
1398    /// therefore drew the overlay at z=0 while the committed sheet sat at z=5 — an
1399    /// off-location sheet. The fix makes the live session adopt the kernel's resolved
1400    /// frame (published under the sketch id in `construction_frames`), so both agree.
1401    #[test]
1402    fn datum_attached_sketch_live_plane_matches_materialized_sheet() {
1403        brep_kernel::clear_history_cache();
1404        // DATUM D2 lifted to z=5; sketch S1 on `D2:XY` with a DELIBERATELY STALE
1405        // identity basis (origin at z=0) plus a fixed 10x6 rectangle profile.
1406        let history = serde_json::json!({
1407            "features": [
1408                {
1409                    "type": "D",
1410                    "inputParams": { "id": "D2",
1411                        "transform": { "position": [0.0, 0.0, 5.0], "rotationEuler": [0.0, 0.0, 0.0], "scale": [1.0, 1.0, 1.0] } },
1412                    "persistentData": {}
1413                },
1414                {
1415                    "type": "S",
1416                    "inputParams": { "id": "S1", "sketchPlane": "D2:XY" },
1417                    "persistentData": {
1418                        // Stale/identity basis at the world origin — the pre-fix live
1419                        // session trusted THIS and drew the overlay at z=0.
1420                        "basis": { "origin": [0.0, 0.0, 0.0], "x": [1.0, 0.0, 0.0], "y": [0.0, 1.0, 0.0], "z": [0.0, 0.0, 1.0] },
1421                        "sketch": {
1422                            "points": [
1423                                { "id": 0, "x": 0.0,  "y": 0.0, "fixed": true },
1424                                { "id": 1, "x": 10.0, "y": 0.0, "fixed": true },
1425                                { "id": 2, "x": 10.0, "y": 6.0, "fixed": true },
1426                                { "id": 3, "x": 0.0,  "y": 6.0, "fixed": true }
1427                            ],
1428                            "geometries": [
1429                                { "id": 10, "type": "line", "points": [0, 1] },
1430                                { "id": 11, "type": "line", "points": [1, 2] },
1431                                { "id": 12, "type": "line", "points": [2, 3] },
1432                                { "id": 13, "type": "line", "points": [3, 0] }
1433                            ],
1434                            "constraints": []
1435                        }
1436                    }
1437                }
1438            ]
1439        })
1440        .to_string();
1441
1442        let mut engine = EngineState::new();
1443        engine.set_history_json(&history).unwrap();
1444
1445        // The committed sheet was ALWAYS materialized against the kernel-resolved
1446        // frame (the datum at z=5) — this half was never broken. Capture its world
1447        // bbox center BEFORE entering (entering the sketch removes its committed
1448        // sheet, which the live editing overlay replaces).
1449        let sheet = engine
1450            .scene
1451            .solids()
1452            .iter()
1453            .find(|s| s.name == "S1")
1454            .expect("committed sketch S1 sheet present");
1455        assert!(sheet.is_sketch, "S1 is a synthesized sketch sheet");
1456        let sheet_center = [
1457            (sheet.bbox.min[0] + sheet.bbox.max[0]) * 0.5,
1458            (sheet.bbox.min[1] + sheet.bbox.max[1]) * 0.5,
1459            (sheet.bbox.min[2] + sheet.bbox.max[2]) * 0.5,
1460        ];
1461        assert!(
1462            (sheet_center[2] - 5.0).abs() < 1e-6,
1463            "committed sheet sits on the datum plane (z=5); got {sheet_center:?}"
1464        );
1465
1466        // Enter sketch mode: the live session plane must adopt the kernel's resolved
1467        // frame (datum origin z=5), NOT the stale basis (z=0). Pre-fix this was z=0.
1468        engine.enter_sketch_mode("S1").expect("enter S1");
1469        let plane = engine.sketch_edit_session().expect("live session").plane;
1470        assert!(
1471            (plane.origin[2] - 5.0).abs() < 1e-6,
1472            "live sketch plane must sit on the datum (z=5), not the stale basis (z=0); got origin {:?}",
1473            plane.origin
1474        );
1475        assert!(
1476            (plane.z_axis[2] - 1.0).abs() < 1e-9,
1477            "live plane normal stays +Z; got {:?}",
1478            plane.z_axis
1479        );
1480
1481        // The literal "sheet matches live" check: the live plane maps the rectangle's
1482        // centroid uv (5, 3) to the committed sheet's world bbox center.
1483        let live_center = plane.to_world(5.0, 3.0);
1484        for axis in 0..3 {
1485            assert!(
1486                (live_center[axis] - sheet_center[axis]).abs() < 1e-6,
1487                "live overlay world {live_center:?} must match the committed sheet center {sheet_center:?}"
1488            );
1489        }
1490    }
1491
1492    /// STEP-2 regression — the FIRST edit session of a brand-new, still-EMPTY
1493    /// face/datum-attached sketch must open its live plane on the resolved reference,
1494    /// not the world origin. The bug: the app's `add_feature` seeds a sketch with an
1495    /// empty `persistentData: {}` (no `sketch` doc — nobody has drawn yet); the kernel
1496    /// SKETCH feature ERRORED on that missing doc and so never published its resolved
1497    /// plane frame, leaving `construction_frames` without the sketch. `enter_sketch_mode`
1498    /// then found no resolved frame and fell through to the persisted `basis` — which
1499    /// is ALSO absent on a fresh sketch — bottoming out at `PlaneFrame::xy()` (the
1500    /// world origin). The user saw the empty sketcher open "on the center of the part"
1501    /// (z=0). Only the first commit wrote the doc, after which the rerun published the
1502    /// frame and the SECOND entry was correct. The fix publishes the resolved frame
1503    /// even for a doc-less/empty sketch, so the FIRST entry already lands on the datum.
1504    ///
1505    /// This deliberately does NOT pre-populate the sketch doc and does NOT enter twice
1506    /// before asserting — it exercises the first-entry-of-an-empty-sketch path directly.
1507    #[test]
1508    fn first_entry_of_empty_datum_sketch_uses_resolved_frame_not_origin() {
1509        brep_kernel::clear_history_cache();
1510        // DATUM D2 lifted to z=5, then a SKETCH S1 referencing `D2:XY` with EMPTY
1511        // persistentData — no `sketch` doc, no `basis` — exactly what the app's
1512        // `add_feature_of_type` creates before the user draws anything.
1513        let history = serde_json::json!({
1514            "features": [
1515                {
1516                    "type": "D",
1517                    "inputParams": { "id": "D2",
1518                        "transform": { "position": [0.0, 0.0, 5.0], "rotationEuler": [0.0, 0.0, 0.0], "scale": [1.0, 1.0, 1.0] } },
1519                    "persistentData": {}
1520                },
1521                {
1522                    "type": "S",
1523                    "inputParams": { "id": "S1", "sketchPlane": "D2:XY" },
1524                    // Brand-new sketch nobody has drawn in yet — no `sketch`, no `basis`.
1525                    "persistentData": {}
1526                }
1527            ]
1528        })
1529        .to_string();
1530
1531        let mut engine = EngineState::new();
1532        engine.set_history_json(&history).unwrap();
1533
1534        // FIRST entry of the still-empty sketch: the live plane must adopt the kernel's
1535        // resolved datum frame (origin [0,0,5]), NOT the XY fallback at the world origin
1536        // (pre-fix this was [0,0,0]).
1537        engine.enter_sketch_mode("S1").expect("enter S1");
1538        let plane = engine.sketch_edit_session().expect("live session").plane;
1539        for (axis, expected) in [0.0_f64, 0.0, 5.0].into_iter().enumerate() {
1540            assert!(
1541                (plane.origin[axis] - expected).abs() < 1e-6,
1542                "first entry of an empty datum-attached sketch must sit on the datum \
1543                 (origin [0,0,5]), not the world origin [0,0,0]; got {:?}",
1544                plane.origin
1545            );
1546        }
1547        assert!(
1548            (plane.z_axis[2] - 1.0).abs() < 1e-9,
1549            "live plane normal stays +Z; got {:?}",
1550            plane.z_axis
1551        );
1552    }
1553}
1554
1555// ===========================================================================
1556// Workbench persistence: the saved document remembers the active workbench.
1557// ===========================================================================
1558//
1559// The engine-owned document round-trip carries a top-level `workbench` field:
1560// `history_request_json` ALWAYS embeds the current `settings.workbench`, and
1561// `set_history_json` lifts it off the kernel recipe and applies it through the
1562// shared settings seam (`apply_settings_json` — the toolbar dropdown's apply
1563// path). Exactly the `metadata` idiom: the engine owns the field, the kernel
1564// never sees it, and every save/open path inherits the behavior for free.
1565#[cfg(test)]
1566mod workbench_persistence_tests {
1567    use super::*;
1568
1569    /// A featureless-but-valid model document, optionally carrying extra
1570    /// top-level fields (the `workbench` under test).
1571    fn doc_with(extra: &[(&str, serde_json::Value)]) -> String {
1572        let mut doc = serde_json::json!({
1573            "expressions": "",
1574            "configurator": {},
1575            "features": []
1576        });
1577        for (key, value) in extra {
1578            doc[*key] = value.clone();
1579        }
1580        doc.to_string()
1581    }
1582
1583    /// The `workbench` field of a serialized document (None when absent).
1584    fn embedded_workbench(document_json: &str) -> Option<String> {
1585        serde_json::from_str::<serde_json::Value>(document_json)
1586            .ok()?
1587            .get("workbench")?
1588            .as_str()
1589            .map(str::to_string)
1590    }
1591
1592    /// SAVE embeds the CURRENT workbench id as the top-level `workbench` field —
1593    /// the live setting, not any stored copy.
1594    #[test]
1595    fn request_json_embeds_the_current_workbench() {
1596        let mut engine = EngineState::new();
1597        engine.set_history_json(&doc_with(&[])).unwrap();
1598        // Default workbench serializes too (the field is ALWAYS written).
1599        assert_eq!(
1600            embedded_workbench(&engine.history_request_json()).as_deref(),
1601            Some("modeling"),
1602            "the default workbench id is embedded on save"
1603        );
1604        // Switch via the dropdown's apply path → the next save carries it.
1605        engine
1606            .apply_settings_json(r#"{"workbench":"sheetMetal"}"#)
1607            .unwrap();
1608        assert_eq!(
1609            embedded_workbench(&engine.history_request_json()).as_deref(),
1610            Some("sheetMetal"),
1611            "save embeds the LIVE workbench id"
1612        );
1613    }
1614
1615    /// LOAD applies a stored `workbench` id to the settings and lifts the field
1616    /// OFF the kernel recipe: after a later switch, re-serializing emits the new
1617    /// live id — no stale copy survives inside the history document.
1618    #[test]
1619    fn load_restores_the_stored_workbench_and_keeps_it_live() {
1620        let mut engine = EngineState::new();
1621        engine
1622            .set_history_json(&doc_with(&[(
1623                "workbench",
1624                serde_json::json!("sheetMetal"),
1625            )]))
1626            .unwrap();
1627        assert_eq!(
1628            engine.settings.workbench, "sheetMetal",
1629            "opening the document switches the active workbench"
1630        );
1631        // The field tracks the LIVE setting (the load lifted the stored copy).
1632        engine
1633            .apply_settings_json(r#"{"workbench":"modeling"}"#)
1634            .unwrap();
1635        assert_eq!(
1636            embedded_workbench(&engine.history_request_json()).as_deref(),
1637            Some("modeling"),
1638            "re-serialize emits the live id — the stored copy was lifted, not kept"
1639        );
1640    }
1641
1642    /// A LEGACY document (no `workbench` field) loads exactly as before: no
1643    /// workbench change, no error. Non-string values count as absent too.
1644    #[test]
1645    fn legacy_document_leaves_the_active_workbench_untouched() {
1646        let mut engine = EngineState::new();
1647        engine
1648            .apply_settings_json(r#"{"workbench":"sheetMetal"}"#)
1649            .unwrap();
1650        engine.set_history_json(&doc_with(&[])).unwrap();
1651        assert_eq!(
1652            engine.settings.workbench, "sheetMetal",
1653            "a legacy document must not yank the user out of their workbench"
1654        );
1655        // A malformed (non-string) field is tolerated as absent — never an error.
1656        engine
1657            .set_history_json(&doc_with(&[("workbench", serde_json::json!(42))]))
1658            .unwrap();
1659        assert_eq!(engine.settings.workbench, "sheetMetal");
1660    }
1661
1662    /// An UNKNOWN stored id loads without error and is stored raw — the settings
1663    /// layer doesn't validate ids (the app-side registry's `resolve()` falls back
1664    /// to the default workbench at every consumption site).
1665    #[test]
1666    fn unknown_stored_workbench_id_loads_without_error() {
1667        let mut engine = EngineState::new();
1668        engine
1669            .set_history_json(&doc_with(&[(
1670                "workbench",
1671                serde_json::json!("conveyorBelts"),
1672            )]))
1673            .unwrap();
1674        assert_eq!(
1675            engine.settings.workbench, "conveyorBelts",
1676            "unknown ids pass through raw; consumers resolve them to the default"
1677        );
1678    }
1679}