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                self.runner.submit_run(request, self.run_generation);
78            }
79            // A parse failure runs nothing: clear the surfaced frames/profiles (so
80            // stale construction geometry does not linger — the scene keeps its
81            // previous solids) and set an error report, then run the shared
82            // post-apply tail synchronously (no kernel work), so this branch shares
83            // the dirty/gizmo/overlay continuation verbatim with a real apply.
84            Err(error) => {
85                self.construction_frames.clear();
86                self.sketch_profiles.clear();
87                self.sketch_axes.clear();
88                self.finish_apply(
89                    serde_json::json!({ "error": format!("history request: {error}") }).to_string(),
90                );
91            }
92        }
93        // Inline applies the submitted run NOW; a thread impl would defer it to a
94        // later frame's `pump`. Either way `history_report` is fresh once the reply
95        // is applied — for Inline that is before this call returns.
96        self.pump();
97        self.history_report.clone()
98    }
99
100    /// Drain every completed run reply and APPLY it — the POLL/APPLY half of the
101    /// M2a seam. Called from [`rerun_history`](Self::rerun_history) for the Inline
102    /// runner's immediate apply, and once per frame from the app so a future async
103    /// runner's completed runs land on the main thread. A reply older than
104    /// [`applied_generation`](Self::applied_generation) (a newer run that finished
105    /// first) is dropped.
106    pub fn pump(&mut self) {
107        while let Some(reply) = self.runner.poll_run() {
108            if reply.generation >= self.applied_generation {
109                self.applied_generation = reply.generation;
110                self.apply_run_output(reply.output);
111            }
112        }
113        // Drain any completed measurement replies too (a background runner surfaces
114        // them a frame after selection); for Inline this is a no-op each frame since
115        // `object_info_json` already pumped its own query same-call.
116        self.pump_queries();
117    }
118
119    /// Whether a measurement query is still in flight (its reply not yet drained) —
120    /// the query analogue of [`run_pending`](Self::run_pending), so the app keeps the
121    /// frame loop alive until a background runner's measurement lands and displays.
122    /// Always `false` for the synchronous Inline runner.
123    pub fn queries_pending(&self) -> bool {
124        !self.pending_query.is_empty()
125    }
126
127    /// Whether a submitted run has not yet been applied (`run_generation !=
128    /// applied_generation`). Always `false` for the synchronous Inline runner
129    /// (submit → immediate `pump` keeps the two in lockstep); a background runner
130    /// uses it to keep the frame loop alive until its reply lands.
131    pub fn run_pending(&self) -> bool {
132        self.run_generation != self.applied_generation
133    }
134
135    /// Whether the display scene currently holds at least one solid. Used by the
136    /// app's async-safe first-frame framing: under a background runner (thread /
137    /// worker) the seed run lands a frame (or many) after boot, so the shell waits
138    /// for `has_solids() && !run_pending()` before its one-shot `zoom_to_fit`.
139    pub fn has_solids(&self) -> bool {
140        !self.scene.solids().is_empty()
141    }
142
143    /// Swap in a different history runner (the platform injects its own — the native
144    /// app installs a [`ThreadRunner`](crate::runner::ThreadRunner); wasm keeps the
145    /// default Inline until M3's worker). Resets the new runner's delta baseline so
146    /// the next run rebuilds fully. Call BEFORE seeding a document so the seed builds
147    /// through the installed runner.
148    pub fn set_runner(&mut self, runner: Box<dyn crate::runner::HistoryRunner>) {
149        self.runner = runner;
150        self.runner.reset();
151    }
152
153    /// Apply a [`SceneRunner`](crate::pipeline::SceneRunner) delta to the display
154    /// scene and build the history report JSON — the APPLY half of the M2a seam.
155    ///
156    /// Reconcile preserving ORDER + reuse: MOVE every current display out of the
157    /// scene ([`RenderScene::drain`](crate::scene::RenderScene::drain)) into a
158    /// name-keyed `kept` map, then reinsert in snapshot order — a fresh entry
159    /// (`Some`) replaces, an UNCHANGED entry (`None`) reuses its moved-out display
160    /// (stable `revision` ⇒ GPU-buffer reuse, Task-1; its `source_handle` equals
161    /// the run's handle by the monotonic-handle reuse invariant). Leftovers in
162    /// `kept` — departed kernel solids AND the previous run's sketch sheets — are
163    /// dropped; `refresh_committed_sketches` (run in the shared continuation after)
164    /// re-adds the sheets, so dropping them here is correct.
165    ///
166    /// Then the report continuation (identical to the pre-seam run): keep the run's
167    /// resolved frames + solved sketch profiles and fold the per-feature timings /
168    /// output-names into the id-keyed report JSON, then hand it to
169    /// [`finish_apply`](Self::finish_apply) — the shared dirty/gizmo/overlay tail
170    /// that the parse-error branch in [`rerun_history`](Self::rerun_history) also
171    /// calls, so both paths share the continuation verbatim.
172    fn apply_run_output(&mut self, output: crate::pipeline::RunOutput) {
173        let crate::pipeline::RunOutput { snapshot, report, provenance } = output;
174
175        // Adopt the run's eager provenance wholesale (drives `creating_feature` +
176        // the Info tab's `creatingFeature` with no cold re-run), and INVALIDATE the
177        // object-info measurement cache + any in-flight query: the geometry changed,
178        // so cached measurements are stale and a pending reply is superseded (a
179        // re-selection re-queries against the fresh geometry).
180        self.provenance = provenance.into_iter().collect();
181        self.info_cache.clear();
182        self.pending_query.clear();
183
184        // Reconcile the scene: move current displays out, reinsert in order.
185        let mut kept: std::collections::HashMap<String, crate::scene::SolidDisplay> = self
186            .scene
187            .drain()
188            .into_iter()
189            .map(|solid| (solid.name.clone(), solid))
190            .collect();
191        for (name, _handle, maybe) in snapshot {
192            match maybe {
193                Some(display) => self.scene.insert_solid(display),
194                None => self
195                    .scene
196                    .insert_solid(kept.remove(&name).expect("keep target present")),
197            }
198        }
199
200        // Keep every plane frame the run resolved (DATUM/PLANE/SKETCH);
201        // `refresh_construction_datums` filters to the D/P producers.
202        self.construction_frames = report.frames.clone();
203        // Keep every solved sketch profile so `refresh_committed_sketches` can
204        // synthesize the committed sketch sheet solids.
205        self.sketch_profiles = report.profiles.clone();
206        // Keep every axis line the run published so the angle gizmo can resolve a
207        // revolve `axis` reference to a world line without re-running.
208        self.sketch_axes = report.axes.clone();
209        // Fold the per-feature timing / output-name pairs into id-keyed maps so the
210        // history-tree UI can look them up by feature id.
211        let timings: serde_json::Map<String, serde_json::Value> = report
212            .feature_timings
213            .iter()
214            .map(|(id, ms)| (id.clone(), serde_json::json!(ms)))
215            .collect();
216        let outputs: serde_json::Map<String, serde_json::Value> = report
217            .feature_outputs
218            .iter()
219            .map(|(id, names)| (id.clone(), serde_json::json!(names)))
220            .collect();
221        let report_json = serde_json::json!({
222            "featureErrors": report.feature_errors,
223            "unresolved": report.unresolved,
224            "displayErrors": report.display_errors,
225            "featureTimings": timings,
226            "featureOutputs": outputs,
227        })
228        .to_string();
229        self.finish_apply(report_json);
230    }
231
232    /// The shared post-apply TAIL: mark dirty, store the report JSON, re-sync an
233    /// armed gizmo, and rebuild the persistent committed-sketch + construction-datum
234    /// overlays. Called after a real run's [`apply_run_output`](Self::apply_run_output)
235    /// AND from [`rerun_history`](Self::rerun_history)'s parse-error branch, so both
236    /// paths run the identical continuation. Callers read the result via
237    /// [`Self::history_report`](Self::history_report_json).
238    fn finish_apply(&mut self, report_json: String) {
239        self.dirty = true;
240        self.history_report = report_json;
241        // Keep an armed gizmo glued to its feature as the model rebuilds. During a
242        // transform drag the re-sync is driven by `transform_drag_to` itself (which
243        // resolves the delta against the frozen grab frame first, then syncs), so
244        // skip it here to avoid a redundant double-feed per drag frame. Transform
245        // mode re-feeds the widget frame; dimension mode re-projects the annotation
246        // leaders onto the rebuilt (param-changed) geometry.
247        if self.transform_gizmo.drag.is_none() {
248            match self.transform_gizmo.mode {
249                GizmoMode::Transform => self.sync_transform_gizmo(),
250                GizmoMode::Dimension => self.refresh_feature_dimension_overlay(),
251                GizmoMode::None => {}
252            }
253        }
254        // Rebuild the persistent committed-sketch overlays against the reconciled
255        // scene (also covers `set_history_json`, which returns this call's result).
256        self.refresh_committed_sketches();
257        // Rebuild the persistent construction datum/plane overlays from the frames
258        // the run just surfaced (D/P features only; sketches render as curves).
259        self.refresh_construction_datums();
260    }
261
262    /// Load a whole history document (a saved part file parses as one); the
263    /// engine now OWNS this recipe. Rolls to the last feature and builds it.
264    ///
265    /// The document's top-level `metadata` field (the Properties-panel
266    /// name-keyed store) is lifted out into [`Self::metadata`] before the feature
267    /// list is handed to the kernel — loading a part REPLACES the store wholesale
268    /// (a document with no `metadata` clears it), mirroring the previous metadata
269    /// manager's load semantics. Round-trips with [`Self::history_request_json`].
270    pub fn set_history_json(&mut self, request_json: &str) -> Result<String, String> {
271        // A document switch is a wholesale model replacement: drop the incremental
272        // cache so the new model starts from a clean slate (no cross-document
273        // staleness, no unbounded cache growth across many opens). The roll/edit
274        // hot path (`rerun_history`) deliberately KEEPS the cache for instant
275        // rollback; this is the ONE place the full clear belongs.
276        brep_kernel::clear_history_cache();
277        // Reset the delta runner's baseline in lockstep with the cache clear so the
278        // new document is a FULL rebuild (no reuse against the prior model's names).
279        self.runner.reset();
280        let mut document: serde_json::Value = serde_json::from_str(request_json)
281            .map_err(|error| format!("history parse: {error}"))?;
282        // Pull `metadata` out of the document so the engine holds the single copy
283        // (kept off the History recipe the kernel executes).
284        let metadata_value = document
285            .as_object_mut()
286            .and_then(|object| object.remove("metadata"));
287        self.metadata.load_json(metadata_value.as_ref());
288        self.history = History::from_request_json(&document.to_string())?;
289        Ok(self.rerun_history())
290    }
291
292    /// The whole history request document (persistence / debugging), with the
293    /// Properties-panel metadata store folded back in as the top-level `metadata`
294    /// field so save→open round-trips it. The field is written only when the
295    /// store is non-empty, so an un-annotated model persists byte-for-byte as
296    /// before.
297    pub fn history_request_json(&self) -> String {
298        if self.metadata.is_empty() {
299            return self.history.request_json();
300        }
301        let mut document: serde_json::Value =
302            serde_json::from_str(&self.history.request_json())
303                .unwrap_or_else(|_| serde_json::json!({}));
304        if let Some(object) = document.as_object_mut() {
305            object.insert("metadata".into(), self.metadata.to_json());
306        }
307        document.to_string()
308    }
309
310    /// The tree listing `{ step, features:[{index,type,id}] }` for the UI panel.
311    pub fn history_listing_json(&self) -> String {
312        self.history.listing_json()
313    }
314
315    /// The last build report JSON.
316    pub fn history_report_json(&self) -> String {
317        self.history_report.clone()
318    }
319
320    pub fn history_len(&self) -> usize {
321        self.history.len()
322    }
323
324    /// The rolled-to (selected) feature index.
325    pub fn history_rollback(&self) -> usize {
326        self.history.rollback()
327    }
328
329    pub fn feature_type_at(&self, index: usize) -> Option<String> {
330        self.history.feature_type(index)
331    }
332
333    pub fn feature_id_at(&self, index: usize) -> Option<String> {
334        self.history.feature_id(index)
335    }
336
337    /// The `inputParams` document of feature `index` (`"null"` if none) — the
338    /// dialog's editing-buffer source.
339    pub fn feature_params_json(&self, index: usize) -> String {
340        self.history
341            .feature_params(index)
342            .map(|v| v.to_string())
343            .unwrap_or_else(|| "null".to_string())
344    }
345
346    /// Mint the id for a NEW feature: `{base}{N}` where `base` is the feature's
347    /// shortName ([`crate::features::feature_short_name`]) and `N` is the part
348    /// history's persistent GLOBAL counter (monotonic, never reused, round-trips
349    /// save/load — see [`History::next_feature_id`]). `&mut` because the counter
350    /// advances; if the caller's `add_feature` then fails the number is simply
351    /// skipped (monotonic-with-gaps is the contract, not an error).
352    pub fn next_feature_id(&mut self, base: &str) -> String {
353        self.history.next_feature_id(base)
354    }
355
356    /// Roll the model to feature `index`: re-run `features[0..=index]`.
357    pub fn roll_to(&mut self, index: usize) -> String {
358        self.history.set_rollback(index);
359        self.rerun_history()
360    }
361
362    /// Replace feature `id`'s input params and re-run at the current rollback →
363    /// the viewport updates live.
364    pub fn update_feature_params(
365        &mut self,
366        id: &str,
367        input_params_json: &str,
368    ) -> Result<String, String> {
369        let params: serde_json::Value = serde_json::from_str(input_params_json)
370            .map_err(|e| format!("feature params parse: {e}"))?;
371        let index = self
372            .history
373            .index_of(id)
374            .ok_or_else(|| format!("no feature with id '{id}'"))?;
375        self.history.set_feature_params(index, params);
376        Ok(self.rerun_history())
377    }
378
379    /// Append a feature (a full `{type, inputParams, …}` descriptor) and roll to
380    /// it. The caller assigns a unique `id` (see [`Self::next_feature_id`]).
381    pub fn add_feature(&mut self, feature_json: &str) -> Result<String, String> {
382        let feature: serde_json::Value =
383            serde_json::from_str(feature_json).map_err(|e| format!("feature parse: {e}"))?;
384        self.history.push_feature(feature);
385        let last = self.history.len().saturating_sub(1);
386        self.history.set_rollback(last);
387        Ok(self.rerun_history())
388    }
389
390    /// Delete the feature with id `id` (no-op if absent) and re-run, clamping the
391    /// rolled-to step.
392    pub fn delete_feature(&mut self, id: &str) -> String {
393        if let Some(index) = self.history.index_of(id) {
394            self.history.remove_feature(index);
395            let step = self
396                .history
397                .rollback()
398                .min(self.history.len().saturating_sub(1));
399            self.history.set_rollback(step);
400        }
401        self.rerun_history()
402    }
403
404    /// Move feature `index` one slot up/down (reorder), keeping it selected.
405    pub fn reorder_feature(&mut self, index: usize, up: bool) -> String {
406        let len = self.history.len();
407        if len >= 2 {
408            let target = if up {
409                index.checked_sub(1)
410            } else if index + 1 < len {
411                Some(index + 1)
412            } else {
413                None
414            };
415            if let Some(target) = target {
416                self.history.swap(index, target);
417                self.history.set_rollback(target);
418            }
419        }
420        self.rerun_history()
421    }
422
423}
424
425impl EngineState {
426    /// Whether an undo step is available (to enable the toolbar's Undo button).
427    pub fn can_undo(&self) -> bool {
428        self.history.can_undo()
429    }
430
431    /// Whether a redo step is available.
432    pub fn can_redo(&self) -> bool {
433        self.history.can_redo()
434    }
435
436    /// Undo the last model mutation: restore the previous document + rolled-to
437    /// step, then re-run + reconcile the scene. Returns the build report; a no-op
438    /// (empty undo stack) returns the last report unchanged.
439    pub fn undo(&mut self) -> String {
440        if self.history.undo() {
441            self.rerun_history()
442        } else {
443            self.history_report.clone()
444        }
445    }
446
447    /// Redo the last undone model mutation (symmetric with [`Self::undo`]).
448    pub fn redo(&mut self) -> String {
449        if self.history.redo() {
450            self.rerun_history()
451        } else {
452            self.history_report.clone()
453        }
454    }
455
456    // --- Selection (Esc clears / viewport click selects) ------------------
457
458}
459
460/// Parse `{name: "#rrggbb", …}` into an sRGB `name → [0..1;3]` map.
461fn parse_color_overrides(json: &str) -> Result<HashMap<String, [f32; 3]>, String> {
462    let value: serde_json::Value =
463        serde_json::from_str(json).map_err(|error| format!("color overrides parse: {error}"))?;
464    let object = value
465        .as_object()
466        .ok_or_else(|| "color overrides must be an object".to_string())?;
467    let mut out = HashMap::new();
468    for (name, raw) in object {
469        if let Some(hex) = raw.as_str() {
470            if let Some(rgb) = crate::style::parse_css_hex(hex) {
471                out.insert(name.clone(), rgb);
472            }
473        }
474    }
475    Ok(out)
476}
477
478// ============================================================================
479// File-management convenience (appended — see the model/file-mgmt slice).
480// Kept as a SEPARATE `impl` block so concurrent edits to the primary block do
481// not conflict; purely additive over the existing history API.
482// ============================================================================
483impl EngineState {
484    /// Load a whole model document (a saved `.BREP.json` recipe) and FRAME it:
485    /// [`set_history_json`](Self::set_history_json) (which rolls to the last
486    /// feature) followed by [`zoom_to_fit`](Self::zoom_to_fit). The one call the
487    /// file panel's **Open** needs — the model IS the engine-owned history, so
488    /// opening a file is loading its request JSON and reframing. Returns the
489    /// build-report JSON.
490    pub fn load_model_and_fit(&mut self, request_json: &str) -> Result<String, String> {
491        let report = self.set_history_json(request_json)?;
492        self.zoom_to_fit();
493        Ok(report)
494    }
495}
496
497
498// ===========================================================================
499// Instant history-rollback: the incremental cache must survive a roll/edit.
500// ===========================================================================
501//
502// Regression guard for the fix that made roll-to-step / edit INSTANT: the engine
503// no longer sledgehammers `clear_history_cache()` before every rerun, so an
504// unchanged upstream prefix (a heavy STEP import + primitives) replays from the
505// kernel's incremental cache (`reused`, timing 0.0) instead of cold re-executing.
506// The correctness worry the sledgehammer guarded — "roll BEFORE a boolean that
507// consumed its target displays the freed/re-used handle" — cannot occur: a
508// boolean clones its inputs and frees only its own intermediates; a consumed
509// input handle is freed exactly when its PRODUCING cache entry is invalidated
510// (never by a downstream consumer). So rolling to the box shows the box.
511#[cfg(test)]
512mod history_cache_rollback_tests {
513    use super::*;
514    use crate::scene::SolidDisplay;
515
516    /// F1 = box `Box` (side 20 → volume 8000), F2 = cylinder `Pin`, F3 = boolean
517    /// `Cut` = SUBTRACT(target=Box, tools=[Pin]). The boolean CONSUMES Box (its
518    /// target) and Pin, removing both names and adding one subtracted solid.
519    fn box_pin_cut_history() -> String {
520        serde_json::json!({
521            "expressions": "",
522            "configurator": {},
523            "features": [
524                {
525                    "type": "P.CU",
526                    "inputParams": {
527                        "id": "Box",
528                        "sizeX": 20.0, "sizeY": 20.0, "sizeZ": 20.0,
529                        "transform": {
530                            "position": [0.0, 0.0, 0.0],
531                            "rotationEuler": [0.0, 0.0, 0.0],
532                            "scale": [1.0, 1.0, 1.0]
533                        },
534                        "boolean": { "targets": [], "operation": "NONE" }
535                    },
536                    "persistentData": {}
537                },
538                {
539                    "type": "P.CY",
540                    "inputParams": {
541                        "id": "Pin",
542                        "radius": 6.0, "height": 30.0,
543                        "transform": {
544                            "position": [10.0, -5.0, 10.0],
545                            "rotationEuler": [0.0, 0.0, 0.0],
546                            "scale": [1.0, 1.0, 1.0]
547                        },
548                        "boolean": { "targets": [], "operation": "NONE" }
549                    },
550                    "persistentData": {}
551                },
552                {
553                    "type": "B",
554                    "inputParams": {
555                        "id": "Cut",
556                        "targetSolid": "Box",
557                        "boolean": { "operation": "SUBTRACT", "targets": ["Pin"] }
558                    },
559                    "persistentData": {}
560                }
561            ]
562        })
563        .to_string()
564    }
565
566    /// Closed-mesh volume via the divergence theorem: V = (1/6) Σ p0·(p1×p2) over
567    /// triangles — the volume of the geometry the USER actually sees on screen.
568    fn display_volume(solid: &SolidDisplay) -> f64 {
569        let p = &solid.mesh.positions;
570        let mut v = 0.0f64;
571        for tri in solid.mesh.indices.chunks_exact(3) {
572            let a = p[tri[0] as usize];
573            let b = p[tri[1] as usize];
574            let c = p[tri[2] as usize];
575            let (a, b, c) = (
576                [a[0] as f64, a[1] as f64, a[2] as f64],
577                [b[0] as f64, b[1] as f64, b[2] as f64],
578                [c[0] as f64, c[1] as f64, c[2] as f64],
579            );
580            // p0 · (p1 × p2)
581            let cross = [
582                b[1] * c[2] - b[2] * c[1],
583                b[2] * c[0] - b[0] * c[2],
584                b[0] * c[1] - b[1] * c[0],
585            ];
586            v += a[0] * cross[0] + a[1] * cross[1] + a[2] * cross[2];
587        }
588        (v / 6.0).abs()
589    }
590
591    /// The timing (ms) the last build reported for feature `id`. A REPLAYED
592    /// (cached) feature reports EXACTLY 0.0; a re-executed one reports its real
593    /// wall-clock (> 0.0 for any real geometry op).
594    fn timing(report: &serde_json::Value, id: &str) -> f64 {
595        report["featureTimings"][id]
596            .as_f64()
597            .unwrap_or_else(|| panic!("no timing for '{id}' in {report}"))
598    }
599
600    /// THE decisive test: rolling to the box BEFORE the boolean that consumed it
601    /// (a) shows the box's geometry (right volume) and (b) replays it from the
602    /// incremental cache (timing 0.0), i.e. instantly — not a cold re-execution.
603    #[test]
604    fn roll_to_step_before_boolean_is_correct_and_cached() {
605        brep_kernel::clear_history_cache(); // hermetic start (shared thread-local cache)
606        let mut engine = EngineState::new();
607        engine.set_history_json(&box_pin_cut_history()).unwrap();
608
609        // Full run: the boolean produced ONE subtracted solid, smaller than the box.
610        assert_eq!(engine.scene.solids().len(), 1);
611        let cut_vol = display_volume(&engine.scene.solids()[0]);
612        assert!(
613            cut_vol < 8000.0 - 1.0,
614            "subtracted solid ({cut_vol}) must be smaller than the 8000 box"
615        );
616
617        // Roll to F1 (the box) — the step BEFORE the boolean.
618        let report: serde_json::Value =
619            serde_json::from_str(&engine.roll_to(0)).unwrap();
620
621        // CORRECTNESS: exactly the box is displayed, full volume 8000 — the cached
622        // replay shows the box's own geometry, NOT a freed/re-used handle.
623        assert_eq!(engine.scene.solids().len(), 1, "only the box after rollback");
624        let box_solid = engine
625            .scene
626            .solid("Box")
627            .expect("box resident after rolling back before the boolean");
628        let box_vol = display_volume(box_solid);
629        assert!(
630            (box_vol - 8000.0).abs() < 1.0,
631            "rolled-back box volume {box_vol} != 8000 (stale/freed handle?)"
632        );
633
634        // INSTANT: the box replayed from the incremental cache (timing 0.0), not a
635        // cold re-execution. THIS is what the sledgehammer removal buys.
636        assert_eq!(
637            timing(&report, "Box"),
638            0.0,
639            "rolled-back box must replay from cache (timing 0.0), not re-execute"
640        );
641    }
642
643    /// Rolling to the box then FORWARD to the boolean again restores the correct
644    /// subtracted geometry AND replays the entire prefix from cache (all timings
645    /// 0.0) — nothing was invalidated by the round-trip, so the roll-forward is
646    /// instant too. Crucially the redisplayed "Box" is the SUBTRACTED result (the
647    /// name is re-bound from the cube's handle to the boolean's), NOT the stale
648    /// full box left over from the rollback — the handle-gated display reuse
649    /// re-tessellates it.
650    #[test]
651    fn roll_forward_after_rollback_restores_geometry_and_replays_all() {
652        brep_kernel::clear_history_cache();
653        let mut engine = EngineState::new();
654        engine.set_history_json(&box_pin_cut_history()).unwrap();
655
656        engine.roll_to(0); // back to the box
657        let rolled = engine.scene.solid("Box").expect("box after rollback");
658        assert!(
659            (display_volume(rolled) - 8000.0).abs() < 1.0,
660            "rolled-back name 'Box' shows the full box"
661        );
662
663        let report: serde_json::Value =
664            serde_json::from_str(&engine.roll_to(2)).unwrap();
665        // The subtracted solid is back — the name "Box" now shows the boolean
666        // result (smaller than the full box), not the stale rollback display.
667        assert_eq!(engine.scene.solids().len(), 1);
668        let vol = display_volume(engine.scene.solid("Box").expect("boolean result"));
669        assert!(
670            vol < 8000.0 - 1.0,
671            "boolean result restored under name 'Box' ({vol}), not the stale 8000 box"
672        );
673        // The WHOLE prefix replayed from cache — the round-trip invalidated nothing.
674        assert_eq!(timing(&report, "Box"), 0.0, "box replayed on roll-forward");
675        assert_eq!(timing(&report, "Pin"), 0.0, "pin replayed on roll-forward");
676        assert_eq!(timing(&report, "Cut"), 0.0, "boolean replayed on roll-forward");
677    }
678
679    /// Editing ONLY the boolean keeps the upstream box + pin cached (timing 0.0);
680    /// just the boolean re-executes — the incremental-dependency win.
681    #[test]
682    fn editing_boolean_keeps_upstream_cached() {
683        brep_kernel::clear_history_cache();
684        let mut engine = EngineState::new();
685        engine.set_history_json(&box_pin_cut_history()).unwrap();
686
687        // Flip the cut to a UNION (fingerprint + geometry both change).
688        let new_params = serde_json::json!({
689            "id": "Cut",
690            "targetSolid": "Box",
691            "boolean": { "operation": "UNION", "targets": ["Pin"] }
692        })
693        .to_string();
694        let report: serde_json::Value =
695            serde_json::from_str(&engine.update_feature_params("Cut", &new_params).unwrap())
696                .unwrap();
697
698        assert_eq!(
699            timing(&report, "Box"),
700            0.0,
701            "box stayed cached across a boolean edit"
702        );
703        assert_eq!(
704            timing(&report, "Pin"),
705            0.0,
706            "pin stayed cached across a boolean edit"
707        );
708        assert!(
709            timing(&report, "Cut") > 0.0,
710            "the edited boolean re-executed"
711        );
712    }
713
714    /// STEP-1 regression — a DATUM/FACE-attached sketch's committed sheet must land
715    /// EXACTLY where the live overlay is drawn. The bug: `enter_sketch_mode` built
716    /// the live session plane from the persisted `basis` ALONE, while the kernel
717    /// materializes the sheet against the frame it resolves LIVE from the
718    /// `sketchPlane` reference (here a datum lifted to z=5). A stale/identity basis
719    /// therefore drew the overlay at z=0 while the committed sheet sat at z=5 — an
720    /// off-location sheet. The fix makes the live session adopt the kernel's resolved
721    /// frame (published under the sketch id in `construction_frames`), so both agree.
722    #[test]
723    fn datum_attached_sketch_live_plane_matches_materialized_sheet() {
724        brep_kernel::clear_history_cache();
725        // DATUM D2 lifted to z=5; sketch S1 on `D2:XY` with a DELIBERATELY STALE
726        // identity basis (origin at z=0) plus a fixed 10x6 rectangle profile.
727        let history = serde_json::json!({
728            "features": [
729                {
730                    "type": "D",
731                    "inputParams": { "id": "D2",
732                        "transform": { "position": [0.0, 0.0, 5.0], "rotationEuler": [0.0, 0.0, 0.0], "scale": [1.0, 1.0, 1.0] } },
733                    "persistentData": {}
734                },
735                {
736                    "type": "S",
737                    "inputParams": { "id": "S1", "sketchPlane": "D2:XY" },
738                    "persistentData": {
739                        // Stale/identity basis at the world origin — the pre-fix live
740                        // session trusted THIS and drew the overlay at z=0.
741                        "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] },
742                        "sketch": {
743                            "points": [
744                                { "id": 0, "x": 0.0,  "y": 0.0, "fixed": true },
745                                { "id": 1, "x": 10.0, "y": 0.0, "fixed": true },
746                                { "id": 2, "x": 10.0, "y": 6.0, "fixed": true },
747                                { "id": 3, "x": 0.0,  "y": 6.0, "fixed": true }
748                            ],
749                            "geometries": [
750                                { "id": 10, "type": "line", "points": [0, 1] },
751                                { "id": 11, "type": "line", "points": [1, 2] },
752                                { "id": 12, "type": "line", "points": [2, 3] },
753                                { "id": 13, "type": "line", "points": [3, 0] }
754                            ],
755                            "constraints": []
756                        }
757                    }
758                }
759            ]
760        })
761        .to_string();
762
763        let mut engine = EngineState::new();
764        engine.set_history_json(&history).unwrap();
765
766        // The committed sheet was ALWAYS materialized against the kernel-resolved
767        // frame (the datum at z=5) — this half was never broken. Capture its world
768        // bbox center BEFORE entering (entering the sketch removes its committed
769        // sheet, which the live editing overlay replaces).
770        let sheet = engine
771            .scene
772            .solids()
773            .iter()
774            .find(|s| s.name == "S1")
775            .expect("committed sketch S1 sheet present");
776        assert!(sheet.is_sketch, "S1 is a synthesized sketch sheet");
777        let sheet_center = [
778            (sheet.bbox.min[0] + sheet.bbox.max[0]) * 0.5,
779            (sheet.bbox.min[1] + sheet.bbox.max[1]) * 0.5,
780            (sheet.bbox.min[2] + sheet.bbox.max[2]) * 0.5,
781        ];
782        assert!(
783            (sheet_center[2] - 5.0).abs() < 1e-6,
784            "committed sheet sits on the datum plane (z=5); got {sheet_center:?}"
785        );
786
787        // Enter sketch mode: the live session plane must adopt the kernel's resolved
788        // frame (datum origin z=5), NOT the stale basis (z=0). Pre-fix this was z=0.
789        engine.enter_sketch_mode("S1").expect("enter S1");
790        let plane = engine.sketch_edit_session().expect("live session").plane;
791        assert!(
792            (plane.origin[2] - 5.0).abs() < 1e-6,
793            "live sketch plane must sit on the datum (z=5), not the stale basis (z=0); got origin {:?}",
794            plane.origin
795        );
796        assert!(
797            (plane.z_axis[2] - 1.0).abs() < 1e-9,
798            "live plane normal stays +Z; got {:?}",
799            plane.z_axis
800        );
801
802        // The literal "sheet matches live" check: the live plane maps the rectangle's
803        // centroid uv (5, 3) to the committed sheet's world bbox center.
804        let live_center = plane.to_world(5.0, 3.0);
805        for axis in 0..3 {
806            assert!(
807                (live_center[axis] - sheet_center[axis]).abs() < 1e-6,
808                "live overlay world {live_center:?} must match the committed sheet center {sheet_center:?}"
809            );
810        }
811    }
812
813    /// STEP-2 regression — the FIRST edit session of a brand-new, still-EMPTY
814    /// face/datum-attached sketch must open its live plane on the resolved reference,
815    /// not the world origin. The bug: the app's `add_feature` seeds a sketch with an
816    /// empty `persistentData: {}` (no `sketch` doc — nobody has drawn yet); the kernel
817    /// SKETCH feature ERRORED on that missing doc and so never published its resolved
818    /// plane frame, leaving `construction_frames` without the sketch. `enter_sketch_mode`
819    /// then found no resolved frame and fell through to the persisted `basis` — which
820    /// is ALSO absent on a fresh sketch — bottoming out at `PlaneFrame::xy()` (the
821    /// world origin). The user saw the empty sketcher open "on the center of the part"
822    /// (z=0). Only the first commit wrote the doc, after which the rerun published the
823    /// frame and the SECOND entry was correct. The fix publishes the resolved frame
824    /// even for a doc-less/empty sketch, so the FIRST entry already lands on the datum.
825    ///
826    /// This deliberately does NOT pre-populate the sketch doc and does NOT enter twice
827    /// before asserting — it exercises the first-entry-of-an-empty-sketch path directly.
828    #[test]
829    fn first_entry_of_empty_datum_sketch_uses_resolved_frame_not_origin() {
830        brep_kernel::clear_history_cache();
831        // DATUM D2 lifted to z=5, then a SKETCH S1 referencing `D2:XY` with EMPTY
832        // persistentData — no `sketch` doc, no `basis` — exactly what the app's
833        // `add_feature_of_type` creates before the user draws anything.
834        let history = serde_json::json!({
835            "features": [
836                {
837                    "type": "D",
838                    "inputParams": { "id": "D2",
839                        "transform": { "position": [0.0, 0.0, 5.0], "rotationEuler": [0.0, 0.0, 0.0], "scale": [1.0, 1.0, 1.0] } },
840                    "persistentData": {}
841                },
842                {
843                    "type": "S",
844                    "inputParams": { "id": "S1", "sketchPlane": "D2:XY" },
845                    // Brand-new sketch nobody has drawn in yet — no `sketch`, no `basis`.
846                    "persistentData": {}
847                }
848            ]
849        })
850        .to_string();
851
852        let mut engine = EngineState::new();
853        engine.set_history_json(&history).unwrap();
854
855        // FIRST entry of the still-empty sketch: the live plane must adopt the kernel's
856        // resolved datum frame (origin [0,0,5]), NOT the XY fallback at the world origin
857        // (pre-fix this was [0,0,0]).
858        engine.enter_sketch_mode("S1").expect("enter S1");
859        let plane = engine.sketch_edit_session().expect("live session").plane;
860        for (axis, expected) in [0.0_f64, 0.0, 5.0].into_iter().enumerate() {
861            assert!(
862                (plane.origin[axis] - expected).abs() < 1e-6,
863                "first entry of an empty datum-attached sketch must sit on the datum \
864                 (origin [0,0,5]), not the world origin [0,0,0]; got {:?}",
865                plane.origin
866            );
867        }
868        assert!(
869            (plane.z_axis[2] - 1.0).abs() < 1e-9,
870            "live plane normal stays +Z; got {:?}",
871            plane.z_axis
872        );
873    }
874}