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