BREP_render 0.1.0

BREP Rust rendering engine: kernel-fed scene store + wgpu renderer (headless artifact, desktop window, and wasm canvas shells).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
use super::*;

// --- Scene feed (R10) -------------------------------------------------

impl EngineState {
    /// Run a whole history and reconcile the display scene (R10 incremental):
    /// reused solids keep their buffers, the rest re-tessellate. `overrides_json`
    /// is an optional `{name: "#rrggbb"}` metadata-color map. Returns the build
    /// report JSON (`{featureErrors, unresolved, displayErrors}`). Marks dirty.
    pub fn run_history_json(
        &mut self,
        request_json: &str,
        overrides_json: Option<&str>,
    ) -> Result<String, String> {
        let request: HistoryRequest = serde_json::from_str(request_json)
            .map_err(|error| format!("history request parse: {error}"))?;
        let overrides = overrides_json
            .map(parse_color_overrides)
            .transpose()?
            .unwrap_or_default();
        let report = crate::pipeline::update_scene_from_history(
            &mut self.scene,
            &request,
            Some(&overrides),
        )?;
        self.dirty = true;
        Ok(serde_json::json!({
            "featureErrors": report.feature_errors,
            "unresolved": report.unresolved,
            "displayErrors": report.display_errors,
        })
        .to_string())
    }

    // --- Engine-owned history (roll-to-step + edit + add/delete/reorder) ---
    //
    // The editable model recipe lives HERE (`self.history`) — the UI keeps no
    // copy of it. It edits + reads the model exclusively through these methods,
    // so the history is UI-agnostic and converges with the "whole history in
    // Rust" pipeline migration.

    /// (Re)run the current rolled-to prefix of the engine's history through the
    /// SAME kernel pipeline and reconcile the display scene. Stores + returns the
    /// build report JSON.
    pub(super) fn rerun_history(&mut self) -> String {
        // Roll-to-step re-runs a TRUNCATED prefix each time and RELIES on the
        // kernel's incremental history cache so an unchanged upstream prefix (a
        // heavy STEP import + primitives) replays instantly (`reused`, timing 0.0)
        // instead of cold re-executing — that is what makes rollback/edit feel
        // instant. The cache is NOT cleared here.
        //
        // Correctness of rolling BEFORE a boolean that consumed its target: a
        // consumed input handle is freed exactly when its PRODUCING cache entry is
        // invalidated (`free_entry`), never by a downstream consumer — a boolean
        // clones its inputs and frees only its own intermediates (verified across
        // boolean/transform/hole/pattern/…). So the box's handle is still resident
        // and rolling to it shows the box, not a freed/re-used handle. Regression
        // guard: `history_cache_rollback_tests`. A wholesale document switch
        // (`set_history_json`) still clears the cache — the roll/edit hot path does
        // NOT.
        //
        // The M2a seam: SUBMIT the history run tagged with a monotonic generation,
        // then `pump` drains its completed reply and APPLIES the delta. For the
        // synchronous [`InlineRunner`](crate::runner::InlineRunner) the submit runs
        // immediately and `pump` applies it in THIS call, so behavior stays
        // byte-identical to the pre-seam in-place reconcile; a later milestone makes
        // the runner a background thread/worker whose reply `pump` applies a frame
        // later (the per-frame `pump` in the app drives that path).
        let request_value = self.history.prefix_request();
        match serde_json::from_value::<HistoryRequest>(request_value) {
            Ok(mut request) => {
                // Carry the live display LOD to the runner (the request is the run
                // boundary the thread/worker receives). The runner re-tessellates
                // every resident mesh when this differs from its last run's lod.
                request.display_lod = self.settings.lod_factor;
                self.run_generation += 1;
                self.runner.submit_run(request, self.run_generation);
            }
            // A parse failure runs nothing: clear the surfaced frames/profiles (so
            // stale construction geometry does not linger — the scene keeps its
            // previous solids) and set an error report, then run the shared
            // post-apply tail synchronously (no kernel work), so this branch shares
            // the dirty/gizmo/overlay continuation verbatim with a real apply.
            Err(error) => {
                self.construction_frames.clear();
                self.sketch_profiles.clear();
                self.sketch_axes.clear();
                self.finish_apply(
                    serde_json::json!({ "error": format!("history request: {error}") }).to_string(),
                );
            }
        }
        // Inline applies the submitted run NOW; a thread impl would defer it to a
        // later frame's `pump`. Either way `history_report` is fresh once the reply
        // is applied — for Inline that is before this call returns.
        self.pump();
        self.history_report.clone()
    }

    /// Drain every completed run reply and APPLY it — the POLL/APPLY half of the
    /// M2a seam. Called from [`rerun_history`](Self::rerun_history) for the Inline
    /// runner's immediate apply, and once per frame from the app so a future async
    /// runner's completed runs land on the main thread. A reply older than
    /// [`applied_generation`](Self::applied_generation) (a newer run that finished
    /// first) is dropped.
    pub fn pump(&mut self) {
        while let Some(reply) = self.runner.poll_run() {
            if reply.generation >= self.applied_generation {
                self.applied_generation = reply.generation;
                self.apply_run_output(reply.output);
            }
        }
        // Drain any completed measurement replies too (a background runner surfaces
        // them a frame after selection); for Inline this is a no-op each frame since
        // `object_info_json` already pumped its own query same-call.
        self.pump_queries();
    }

    /// Whether a measurement query is still in flight (its reply not yet drained) —
    /// the query analogue of [`run_pending`](Self::run_pending), so the app keeps the
    /// frame loop alive until a background runner's measurement lands and displays.
    /// Always `false` for the synchronous Inline runner.
    pub fn queries_pending(&self) -> bool {
        !self.pending_query.is_empty()
    }

    /// Whether a submitted run has not yet been applied (`run_generation !=
    /// applied_generation`). Always `false` for the synchronous Inline runner
    /// (submit → immediate `pump` keeps the two in lockstep); a background runner
    /// uses it to keep the frame loop alive until its reply lands.
    pub fn run_pending(&self) -> bool {
        self.run_generation != self.applied_generation
    }

    /// Whether the display scene currently holds at least one solid. Used by the
    /// app's async-safe first-frame framing: under a background runner (thread /
    /// worker) the seed run lands a frame (or many) after boot, so the shell waits
    /// for `has_solids() && !run_pending()` before its one-shot `zoom_to_fit`.
    pub fn has_solids(&self) -> bool {
        !self.scene.solids().is_empty()
    }

    /// Swap in a different history runner (the platform injects its own — the native
    /// app installs a [`ThreadRunner`](crate::runner::ThreadRunner); wasm keeps the
    /// default Inline until M3's worker). Resets the new runner's delta baseline so
    /// the next run rebuilds fully. Call BEFORE seeding a document so the seed builds
    /// through the installed runner.
    pub fn set_runner(&mut self, runner: Box<dyn crate::runner::HistoryRunner>) {
        self.runner = runner;
        self.runner.reset();
    }

    /// Apply a [`SceneRunner`](crate::pipeline::SceneRunner) delta to the display
    /// scene and build the history report JSON — the APPLY half of the M2a seam.
    ///
    /// Reconcile preserving ORDER + reuse: MOVE every current display out of the
    /// scene ([`RenderScene::drain`](crate::scene::RenderScene::drain)) into a
    /// name-keyed `kept` map, then reinsert in snapshot order — a fresh entry
    /// (`Some`) replaces, an UNCHANGED entry (`None`) reuses its moved-out display
    /// (stable `revision` ⇒ GPU-buffer reuse, Task-1; its `source_handle` equals
    /// the run's handle by the monotonic-handle reuse invariant). Leftovers in
    /// `kept` — departed kernel solids AND the previous run's sketch sheets — are
    /// dropped; `refresh_committed_sketches` (run in the shared continuation after)
    /// re-adds the sheets, so dropping them here is correct.
    ///
    /// Then the report continuation (identical to the pre-seam run): keep the run's
    /// resolved frames + solved sketch profiles and fold the per-feature timings /
    /// output-names into the id-keyed report JSON, then hand it to
    /// [`finish_apply`](Self::finish_apply) — the shared dirty/gizmo/overlay tail
    /// that the parse-error branch in [`rerun_history`](Self::rerun_history) also
    /// calls, so both paths share the continuation verbatim.
    fn apply_run_output(&mut self, output: crate::pipeline::RunOutput) {
        let crate::pipeline::RunOutput { snapshot, report, provenance } = output;

        // Adopt the run's eager provenance wholesale (drives `creating_feature` +
        // the Info tab's `creatingFeature` with no cold re-run), and INVALIDATE the
        // object-info measurement cache + any in-flight query: the geometry changed,
        // so cached measurements are stale and a pending reply is superseded (a
        // re-selection re-queries against the fresh geometry).
        self.provenance = provenance.into_iter().collect();
        self.info_cache.clear();
        self.pending_query.clear();

        // Reconcile the scene: move current displays out, reinsert in order.
        let mut kept: std::collections::HashMap<String, crate::scene::SolidDisplay> = self
            .scene
            .drain()
            .into_iter()
            .map(|solid| (solid.name.clone(), solid))
            .collect();
        for (name, _handle, maybe) in snapshot {
            match maybe {
                Some(display) => self.scene.insert_solid(display),
                None => self
                    .scene
                    .insert_solid(kept.remove(&name).expect("keep target present")),
            }
        }

        // Keep every plane frame the run resolved (DATUM/PLANE/SKETCH);
        // `refresh_construction_datums` filters to the D/P producers.
        self.construction_frames = report.frames.clone();
        // Keep every solved sketch profile so `refresh_committed_sketches` can
        // synthesize the committed sketch sheet solids.
        self.sketch_profiles = report.profiles.clone();
        // Keep every axis line the run published so the angle gizmo can resolve a
        // revolve `axis` reference to a world line without re-running.
        self.sketch_axes = report.axes.clone();
        // Fold the per-feature timing / output-name pairs into id-keyed maps so the
        // history-tree UI can look them up by feature id.
        let timings: serde_json::Map<String, serde_json::Value> = report
            .feature_timings
            .iter()
            .map(|(id, ms)| (id.clone(), serde_json::json!(ms)))
            .collect();
        let outputs: serde_json::Map<String, serde_json::Value> = report
            .feature_outputs
            .iter()
            .map(|(id, names)| (id.clone(), serde_json::json!(names)))
            .collect();
        let report_json = serde_json::json!({
            "featureErrors": report.feature_errors,
            "unresolved": report.unresolved,
            "displayErrors": report.display_errors,
            "featureTimings": timings,
            "featureOutputs": outputs,
        })
        .to_string();
        self.finish_apply(report_json);
    }

    /// The shared post-apply TAIL: mark dirty, store the report JSON, re-sync an
    /// armed gizmo, and rebuild the persistent committed-sketch + construction-datum
    /// overlays. Called after a real run's [`apply_run_output`](Self::apply_run_output)
    /// AND from [`rerun_history`](Self::rerun_history)'s parse-error branch, so both
    /// paths run the identical continuation. Callers read the result via
    /// [`Self::history_report`](Self::history_report_json).
    fn finish_apply(&mut self, report_json: String) {
        self.dirty = true;
        self.history_report = report_json;
        // Keep an armed gizmo glued to its feature as the model rebuilds. During a
        // transform drag the re-sync is driven by `transform_drag_to` itself (which
        // resolves the delta against the frozen grab frame first, then syncs), so
        // skip it here to avoid a redundant double-feed per drag frame. Transform
        // mode re-feeds the widget frame; dimension mode re-projects the annotation
        // leaders onto the rebuilt (param-changed) geometry.
        if self.transform_gizmo.drag.is_none() {
            match self.transform_gizmo.mode {
                GizmoMode::Transform => self.sync_transform_gizmo(),
                GizmoMode::Dimension => self.refresh_feature_dimension_overlay(),
                GizmoMode::None => {}
            }
        }
        // Rebuild the persistent committed-sketch overlays against the reconciled
        // scene (also covers `set_history_json`, which returns this call's result).
        self.refresh_committed_sketches();
        // Rebuild the persistent construction datum/plane overlays from the frames
        // the run just surfaced (D/P features only; sketches render as curves).
        self.refresh_construction_datums();
    }

    /// Load a whole history document (a saved part file parses as one); the
    /// engine now OWNS this recipe. Rolls to the last feature and builds it.
    ///
    /// The document's top-level `metadata` field (the Properties-panel
    /// name-keyed store) is lifted out into [`Self::metadata`] before the feature
    /// list is handed to the kernel — loading a part REPLACES the store wholesale
    /// (a document with no `metadata` clears it), mirroring the previous metadata
    /// manager's load semantics. Round-trips with [`Self::history_request_json`].
    pub fn set_history_json(&mut self, request_json: &str) -> Result<String, String> {
        // A document switch is a wholesale model replacement: drop the incremental
        // cache so the new model starts from a clean slate (no cross-document
        // staleness, no unbounded cache growth across many opens). The roll/edit
        // hot path (`rerun_history`) deliberately KEEPS the cache for instant
        // rollback; this is the ONE place the full clear belongs.
        brep_kernel::clear_history_cache();
        // Reset the delta runner's baseline in lockstep with the cache clear so the
        // new document is a FULL rebuild (no reuse against the prior model's names).
        self.runner.reset();
        let mut document: serde_json::Value = serde_json::from_str(request_json)
            .map_err(|error| format!("history parse: {error}"))?;
        // Pull `metadata` out of the document so the engine holds the single copy
        // (kept off the History recipe the kernel executes).
        let metadata_value = document
            .as_object_mut()
            .and_then(|object| object.remove("metadata"));
        self.metadata.load_json(metadata_value.as_ref());
        self.history = History::from_request_json(&document.to_string())?;
        Ok(self.rerun_history())
    }

    /// The whole history request document (persistence / debugging), with the
    /// Properties-panel metadata store folded back in as the top-level `metadata`
    /// field so save→open round-trips it. The field is written only when the
    /// store is non-empty, so an un-annotated model persists byte-for-byte as
    /// before.
    pub fn history_request_json(&self) -> String {
        if self.metadata.is_empty() {
            return self.history.request_json();
        }
        let mut document: serde_json::Value =
            serde_json::from_str(&self.history.request_json())
                .unwrap_or_else(|_| serde_json::json!({}));
        if let Some(object) = document.as_object_mut() {
            object.insert("metadata".into(), self.metadata.to_json());
        }
        document.to_string()
    }

    /// The tree listing `{ step, features:[{index,type,id}] }` for the UI panel.
    pub fn history_listing_json(&self) -> String {
        self.history.listing_json()
    }

    /// The last build report JSON.
    pub fn history_report_json(&self) -> String {
        self.history_report.clone()
    }

    pub fn history_len(&self) -> usize {
        self.history.len()
    }

    /// The rolled-to (selected) feature index.
    pub fn history_rollback(&self) -> usize {
        self.history.rollback()
    }

    pub fn feature_type_at(&self, index: usize) -> Option<String> {
        self.history.feature_type(index)
    }

    pub fn feature_id_at(&self, index: usize) -> Option<String> {
        self.history.feature_id(index)
    }

    /// The `inputParams` document of feature `index` (`"null"` if none) — the
    /// dialog's editing-buffer source.
    pub fn feature_params_json(&self, index: usize) -> String {
        self.history
            .feature_params(index)
            .map(|v| v.to_string())
            .unwrap_or_else(|| "null".to_string())
    }

    /// Mint the id for a NEW feature: `{base}{N}` where `base` is the feature's
    /// shortName ([`crate::features::feature_short_name`]) and `N` is the part
    /// history's persistent GLOBAL counter (monotonic, never reused, round-trips
    /// save/load — see [`History::next_feature_id`]). `&mut` because the counter
    /// advances; if the caller's `add_feature` then fails the number is simply
    /// skipped (monotonic-with-gaps is the contract, not an error).
    pub fn next_feature_id(&mut self, base: &str) -> String {
        self.history.next_feature_id(base)
    }

    /// Roll the model to feature `index`: re-run `features[0..=index]`.
    pub fn roll_to(&mut self, index: usize) -> String {
        self.history.set_rollback(index);
        self.rerun_history()
    }

    /// Replace feature `id`'s input params and re-run at the current rollback →
    /// the viewport updates live.
    pub fn update_feature_params(
        &mut self,
        id: &str,
        input_params_json: &str,
    ) -> Result<String, String> {
        let params: serde_json::Value = serde_json::from_str(input_params_json)
            .map_err(|e| format!("feature params parse: {e}"))?;
        let index = self
            .history
            .index_of(id)
            .ok_or_else(|| format!("no feature with id '{id}'"))?;
        self.history.set_feature_params(index, params);
        Ok(self.rerun_history())
    }

    /// Append a feature (a full `{type, inputParams, …}` descriptor) and roll to
    /// it. The caller assigns a unique `id` (see [`Self::next_feature_id`]).
    pub fn add_feature(&mut self, feature_json: &str) -> Result<String, String> {
        let feature: serde_json::Value =
            serde_json::from_str(feature_json).map_err(|e| format!("feature parse: {e}"))?;
        self.history.push_feature(feature);
        let last = self.history.len().saturating_sub(1);
        self.history.set_rollback(last);
        Ok(self.rerun_history())
    }

    /// Delete the feature with id `id` (no-op if absent) and re-run, clamping the
    /// rolled-to step.
    pub fn delete_feature(&mut self, id: &str) -> String {
        if let Some(index) = self.history.index_of(id) {
            self.history.remove_feature(index);
            let step = self
                .history
                .rollback()
                .min(self.history.len().saturating_sub(1));
            self.history.set_rollback(step);
        }
        self.rerun_history()
    }

    /// Move feature `index` one slot up/down (reorder), keeping it selected.
    pub fn reorder_feature(&mut self, index: usize, up: bool) -> String {
        let len = self.history.len();
        if len >= 2 {
            let target = if up {
                index.checked_sub(1)
            } else if index + 1 < len {
                Some(index + 1)
            } else {
                None
            };
            if let Some(target) = target {
                self.history.swap(index, target);
                self.history.set_rollback(target);
            }
        }
        self.rerun_history()
    }

}

impl EngineState {
    /// Whether an undo step is available (to enable the toolbar's Undo button).
    pub fn can_undo(&self) -> bool {
        self.history.can_undo()
    }

    /// Whether a redo step is available.
    pub fn can_redo(&self) -> bool {
        self.history.can_redo()
    }

    /// Undo the last model mutation: restore the previous document + rolled-to
    /// step, then re-run + reconcile the scene. Returns the build report; a no-op
    /// (empty undo stack) returns the last report unchanged.
    pub fn undo(&mut self) -> String {
        if self.history.undo() {
            self.rerun_history()
        } else {
            self.history_report.clone()
        }
    }

    /// Redo the last undone model mutation (symmetric with [`Self::undo`]).
    pub fn redo(&mut self) -> String {
        if self.history.redo() {
            self.rerun_history()
        } else {
            self.history_report.clone()
        }
    }

    // --- Selection (Esc clears / viewport click selects) ------------------

}

/// Parse `{name: "#rrggbb", …}` into an sRGB `name → [0..1;3]` map.
fn parse_color_overrides(json: &str) -> Result<HashMap<String, [f32; 3]>, String> {
    let value: serde_json::Value =
        serde_json::from_str(json).map_err(|error| format!("color overrides parse: {error}"))?;
    let object = value
        .as_object()
        .ok_or_else(|| "color overrides must be an object".to_string())?;
    let mut out = HashMap::new();
    for (name, raw) in object {
        if let Some(hex) = raw.as_str() {
            if let Some(rgb) = crate::style::parse_css_hex(hex) {
                out.insert(name.clone(), rgb);
            }
        }
    }
    Ok(out)
}

// ============================================================================
// File-management convenience (appended — see the model/file-mgmt slice).
// Kept as a SEPARATE `impl` block so concurrent edits to the primary block do
// not conflict; purely additive over the existing history API.
// ============================================================================
impl EngineState {
    /// Load a whole model document (a saved `.BREP.json` recipe) and FRAME it:
    /// [`set_history_json`](Self::set_history_json) (which rolls to the last
    /// feature) followed by [`zoom_to_fit`](Self::zoom_to_fit). The one call the
    /// file panel's **Open** needs — the model IS the engine-owned history, so
    /// opening a file is loading its request JSON and reframing. Returns the
    /// build-report JSON.
    pub fn load_model_and_fit(&mut self, request_json: &str) -> Result<String, String> {
        let report = self.set_history_json(request_json)?;
        self.zoom_to_fit();
        Ok(report)
    }
}


// ===========================================================================
// Instant history-rollback: the incremental cache must survive a roll/edit.
// ===========================================================================
//
// Regression guard for the fix that made roll-to-step / edit INSTANT: the engine
// no longer sledgehammers `clear_history_cache()` before every rerun, so an
// unchanged upstream prefix (a heavy STEP import + primitives) replays from the
// kernel's incremental cache (`reused`, timing 0.0) instead of cold re-executing.
// The correctness worry the sledgehammer guarded — "roll BEFORE a boolean that
// consumed its target displays the freed/re-used handle" — cannot occur: a
// boolean clones its inputs and frees only its own intermediates; a consumed
// input handle is freed exactly when its PRODUCING cache entry is invalidated
// (never by a downstream consumer). So rolling to the box shows the box.
#[cfg(test)]
mod history_cache_rollback_tests {
    use super::*;
    use crate::scene::SolidDisplay;

    /// F1 = box `Box` (side 20 → volume 8000), F2 = cylinder `Pin`, F3 = boolean
    /// `Cut` = SUBTRACT(target=Box, tools=[Pin]). The boolean CONSUMES Box (its
    /// target) and Pin, removing both names and adding one subtracted solid.
    fn box_pin_cut_history() -> String {
        serde_json::json!({
            "expressions": "",
            "configurator": {},
            "features": [
                {
                    "type": "P.CU",
                    "inputParams": {
                        "id": "Box",
                        "sizeX": 20.0, "sizeY": 20.0, "sizeZ": 20.0,
                        "transform": {
                            "position": [0.0, 0.0, 0.0],
                            "rotationEuler": [0.0, 0.0, 0.0],
                            "scale": [1.0, 1.0, 1.0]
                        },
                        "boolean": { "targets": [], "operation": "NONE" }
                    },
                    "persistentData": {}
                },
                {
                    "type": "P.CY",
                    "inputParams": {
                        "id": "Pin",
                        "radius": 6.0, "height": 30.0,
                        "transform": {
                            "position": [10.0, -5.0, 10.0],
                            "rotationEuler": [0.0, 0.0, 0.0],
                            "scale": [1.0, 1.0, 1.0]
                        },
                        "boolean": { "targets": [], "operation": "NONE" }
                    },
                    "persistentData": {}
                },
                {
                    "type": "B",
                    "inputParams": {
                        "id": "Cut",
                        "targetSolid": "Box",
                        "boolean": { "operation": "SUBTRACT", "targets": ["Pin"] }
                    },
                    "persistentData": {}
                }
            ]
        })
        .to_string()
    }

    /// Closed-mesh volume via the divergence theorem: V = (1/6) Σ p0·(p1×p2) over
    /// triangles — the volume of the geometry the USER actually sees on screen.
    fn display_volume(solid: &SolidDisplay) -> f64 {
        let p = &solid.mesh.positions;
        let mut v = 0.0f64;
        for tri in solid.mesh.indices.chunks_exact(3) {
            let a = p[tri[0] as usize];
            let b = p[tri[1] as usize];
            let c = p[tri[2] as usize];
            let (a, b, c) = (
                [a[0] as f64, a[1] as f64, a[2] as f64],
                [b[0] as f64, b[1] as f64, b[2] as f64],
                [c[0] as f64, c[1] as f64, c[2] as f64],
            );
            // p0 · (p1 × p2)
            let cross = [
                b[1] * c[2] - b[2] * c[1],
                b[2] * c[0] - b[0] * c[2],
                b[0] * c[1] - b[1] * c[0],
            ];
            v += a[0] * cross[0] + a[1] * cross[1] + a[2] * cross[2];
        }
        (v / 6.0).abs()
    }

    /// The timing (ms) the last build reported for feature `id`. A REPLAYED
    /// (cached) feature reports EXACTLY 0.0; a re-executed one reports its real
    /// wall-clock (> 0.0 for any real geometry op).
    fn timing(report: &serde_json::Value, id: &str) -> f64 {
        report["featureTimings"][id]
            .as_f64()
            .unwrap_or_else(|| panic!("no timing for '{id}' in {report}"))
    }

    /// THE decisive test: rolling to the box BEFORE the boolean that consumed it
    /// (a) shows the box's geometry (right volume) and (b) replays it from the
    /// incremental cache (timing 0.0), i.e. instantly — not a cold re-execution.
    #[test]
    fn roll_to_step_before_boolean_is_correct_and_cached() {
        brep_kernel::clear_history_cache(); // hermetic start (shared thread-local cache)
        let mut engine = EngineState::new();
        engine.set_history_json(&box_pin_cut_history()).unwrap();

        // Full run: the boolean produced ONE subtracted solid, smaller than the box.
        assert_eq!(engine.scene.solids().len(), 1);
        let cut_vol = display_volume(&engine.scene.solids()[0]);
        assert!(
            cut_vol < 8000.0 - 1.0,
            "subtracted solid ({cut_vol}) must be smaller than the 8000 box"
        );

        // Roll to F1 (the box) — the step BEFORE the boolean.
        let report: serde_json::Value =
            serde_json::from_str(&engine.roll_to(0)).unwrap();

        // CORRECTNESS: exactly the box is displayed, full volume 8000 — the cached
        // replay shows the box's own geometry, NOT a freed/re-used handle.
        assert_eq!(engine.scene.solids().len(), 1, "only the box after rollback");
        let box_solid = engine
            .scene
            .solid("Box")
            .expect("box resident after rolling back before the boolean");
        let box_vol = display_volume(box_solid);
        assert!(
            (box_vol - 8000.0).abs() < 1.0,
            "rolled-back box volume {box_vol} != 8000 (stale/freed handle?)"
        );

        // INSTANT: the box replayed from the incremental cache (timing 0.0), not a
        // cold re-execution. THIS is what the sledgehammer removal buys.
        assert_eq!(
            timing(&report, "Box"),
            0.0,
            "rolled-back box must replay from cache (timing 0.0), not re-execute"
        );
    }

    /// Rolling to the box then FORWARD to the boolean again restores the correct
    /// subtracted geometry AND replays the entire prefix from cache (all timings
    /// 0.0) — nothing was invalidated by the round-trip, so the roll-forward is
    /// instant too. Crucially the redisplayed "Box" is the SUBTRACTED result (the
    /// name is re-bound from the cube's handle to the boolean's), NOT the stale
    /// full box left over from the rollback — the handle-gated display reuse
    /// re-tessellates it.
    #[test]
    fn roll_forward_after_rollback_restores_geometry_and_replays_all() {
        brep_kernel::clear_history_cache();
        let mut engine = EngineState::new();
        engine.set_history_json(&box_pin_cut_history()).unwrap();

        engine.roll_to(0); // back to the box
        let rolled = engine.scene.solid("Box").expect("box after rollback");
        assert!(
            (display_volume(rolled) - 8000.0).abs() < 1.0,
            "rolled-back name 'Box' shows the full box"
        );

        let report: serde_json::Value =
            serde_json::from_str(&engine.roll_to(2)).unwrap();
        // The subtracted solid is back — the name "Box" now shows the boolean
        // result (smaller than the full box), not the stale rollback display.
        assert_eq!(engine.scene.solids().len(), 1);
        let vol = display_volume(engine.scene.solid("Box").expect("boolean result"));
        assert!(
            vol < 8000.0 - 1.0,
            "boolean result restored under name 'Box' ({vol}), not the stale 8000 box"
        );
        // The WHOLE prefix replayed from cache — the round-trip invalidated nothing.
        assert_eq!(timing(&report, "Box"), 0.0, "box replayed on roll-forward");
        assert_eq!(timing(&report, "Pin"), 0.0, "pin replayed on roll-forward");
        assert_eq!(timing(&report, "Cut"), 0.0, "boolean replayed on roll-forward");
    }

    /// Editing ONLY the boolean keeps the upstream box + pin cached (timing 0.0);
    /// just the boolean re-executes — the incremental-dependency win.
    #[test]
    fn editing_boolean_keeps_upstream_cached() {
        brep_kernel::clear_history_cache();
        let mut engine = EngineState::new();
        engine.set_history_json(&box_pin_cut_history()).unwrap();

        // Flip the cut to a UNION (fingerprint + geometry both change).
        let new_params = serde_json::json!({
            "id": "Cut",
            "targetSolid": "Box",
            "boolean": { "operation": "UNION", "targets": ["Pin"] }
        })
        .to_string();
        let report: serde_json::Value =
            serde_json::from_str(&engine.update_feature_params("Cut", &new_params).unwrap())
                .unwrap();

        assert_eq!(
            timing(&report, "Box"),
            0.0,
            "box stayed cached across a boolean edit"
        );
        assert_eq!(
            timing(&report, "Pin"),
            0.0,
            "pin stayed cached across a boolean edit"
        );
        assert!(
            timing(&report, "Cut") > 0.0,
            "the edited boolean re-executed"
        );
    }

    /// STEP-1 regression — a DATUM/FACE-attached sketch's committed sheet must land
    /// EXACTLY where the live overlay is drawn. The bug: `enter_sketch_mode` built
    /// the live session plane from the persisted `basis` ALONE, while the kernel
    /// materializes the sheet against the frame it resolves LIVE from the
    /// `sketchPlane` reference (here a datum lifted to z=5). A stale/identity basis
    /// therefore drew the overlay at z=0 while the committed sheet sat at z=5 — an
    /// off-location sheet. The fix makes the live session adopt the kernel's resolved
    /// frame (published under the sketch id in `construction_frames`), so both agree.
    #[test]
    fn datum_attached_sketch_live_plane_matches_materialized_sheet() {
        brep_kernel::clear_history_cache();
        // DATUM D2 lifted to z=5; sketch S1 on `D2:XY` with a DELIBERATELY STALE
        // identity basis (origin at z=0) plus a fixed 10x6 rectangle profile.
        let history = serde_json::json!({
            "features": [
                {
                    "type": "D",
                    "inputParams": { "id": "D2",
                        "transform": { "position": [0.0, 0.0, 5.0], "rotationEuler": [0.0, 0.0, 0.0], "scale": [1.0, 1.0, 1.0] } },
                    "persistentData": {}
                },
                {
                    "type": "S",
                    "inputParams": { "id": "S1", "sketchPlane": "D2:XY" },
                    "persistentData": {
                        // Stale/identity basis at the world origin — the pre-fix live
                        // session trusted THIS and drew the overlay at z=0.
                        "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] },
                        "sketch": {
                            "points": [
                                { "id": 0, "x": 0.0,  "y": 0.0, "fixed": true },
                                { "id": 1, "x": 10.0, "y": 0.0, "fixed": true },
                                { "id": 2, "x": 10.0, "y": 6.0, "fixed": true },
                                { "id": 3, "x": 0.0,  "y": 6.0, "fixed": true }
                            ],
                            "geometries": [
                                { "id": 10, "type": "line", "points": [0, 1] },
                                { "id": 11, "type": "line", "points": [1, 2] },
                                { "id": 12, "type": "line", "points": [2, 3] },
                                { "id": 13, "type": "line", "points": [3, 0] }
                            ],
                            "constraints": []
                        }
                    }
                }
            ]
        })
        .to_string();

        let mut engine = EngineState::new();
        engine.set_history_json(&history).unwrap();

        // The committed sheet was ALWAYS materialized against the kernel-resolved
        // frame (the datum at z=5) — this half was never broken. Capture its world
        // bbox center BEFORE entering (entering the sketch removes its committed
        // sheet, which the live editing overlay replaces).
        let sheet = engine
            .scene
            .solids()
            .iter()
            .find(|s| s.name == "S1")
            .expect("committed sketch S1 sheet present");
        assert!(sheet.is_sketch, "S1 is a synthesized sketch sheet");
        let sheet_center = [
            (sheet.bbox.min[0] + sheet.bbox.max[0]) * 0.5,
            (sheet.bbox.min[1] + sheet.bbox.max[1]) * 0.5,
            (sheet.bbox.min[2] + sheet.bbox.max[2]) * 0.5,
        ];
        assert!(
            (sheet_center[2] - 5.0).abs() < 1e-6,
            "committed sheet sits on the datum plane (z=5); got {sheet_center:?}"
        );

        // Enter sketch mode: the live session plane must adopt the kernel's resolved
        // frame (datum origin z=5), NOT the stale basis (z=0). Pre-fix this was z=0.
        engine.enter_sketch_mode("S1").expect("enter S1");
        let plane = engine.sketch_edit_session().expect("live session").plane;
        assert!(
            (plane.origin[2] - 5.0).abs() < 1e-6,
            "live sketch plane must sit on the datum (z=5), not the stale basis (z=0); got origin {:?}",
            plane.origin
        );
        assert!(
            (plane.z_axis[2] - 1.0).abs() < 1e-9,
            "live plane normal stays +Z; got {:?}",
            plane.z_axis
        );

        // The literal "sheet matches live" check: the live plane maps the rectangle's
        // centroid uv (5, 3) to the committed sheet's world bbox center.
        let live_center = plane.to_world(5.0, 3.0);
        for axis in 0..3 {
            assert!(
                (live_center[axis] - sheet_center[axis]).abs() < 1e-6,
                "live overlay world {live_center:?} must match the committed sheet center {sheet_center:?}"
            );
        }
    }

    /// STEP-2 regression — the FIRST edit session of a brand-new, still-EMPTY
    /// face/datum-attached sketch must open its live plane on the resolved reference,
    /// not the world origin. The bug: the app's `add_feature` seeds a sketch with an
    /// empty `persistentData: {}` (no `sketch` doc — nobody has drawn yet); the kernel
    /// SKETCH feature ERRORED on that missing doc and so never published its resolved
    /// plane frame, leaving `construction_frames` without the sketch. `enter_sketch_mode`
    /// then found no resolved frame and fell through to the persisted `basis` — which
    /// is ALSO absent on a fresh sketch — bottoming out at `PlaneFrame::xy()` (the
    /// world origin). The user saw the empty sketcher open "on the center of the part"
    /// (z=0). Only the first commit wrote the doc, after which the rerun published the
    /// frame and the SECOND entry was correct. The fix publishes the resolved frame
    /// even for a doc-less/empty sketch, so the FIRST entry already lands on the datum.
    ///
    /// This deliberately does NOT pre-populate the sketch doc and does NOT enter twice
    /// before asserting — it exercises the first-entry-of-an-empty-sketch path directly.
    #[test]
    fn first_entry_of_empty_datum_sketch_uses_resolved_frame_not_origin() {
        brep_kernel::clear_history_cache();
        // DATUM D2 lifted to z=5, then a SKETCH S1 referencing `D2:XY` with EMPTY
        // persistentData — no `sketch` doc, no `basis` — exactly what the app's
        // `add_feature_of_type` creates before the user draws anything.
        let history = serde_json::json!({
            "features": [
                {
                    "type": "D",
                    "inputParams": { "id": "D2",
                        "transform": { "position": [0.0, 0.0, 5.0], "rotationEuler": [0.0, 0.0, 0.0], "scale": [1.0, 1.0, 1.0] } },
                    "persistentData": {}
                },
                {
                    "type": "S",
                    "inputParams": { "id": "S1", "sketchPlane": "D2:XY" },
                    // Brand-new sketch nobody has drawn in yet — no `sketch`, no `basis`.
                    "persistentData": {}
                }
            ]
        })
        .to_string();

        let mut engine = EngineState::new();
        engine.set_history_json(&history).unwrap();

        // FIRST entry of the still-empty sketch: the live plane must adopt the kernel's
        // resolved datum frame (origin [0,0,5]), NOT the XY fallback at the world origin
        // (pre-fix this was [0,0,0]).
        engine.enter_sketch_mode("S1").expect("enter S1");
        let plane = engine.sketch_edit_session().expect("live session").plane;
        for (axis, expected) in [0.0_f64, 0.0, 5.0].into_iter().enumerate() {
            assert!(
                (plane.origin[axis] - expected).abs() < 1e-6,
                "first entry of an empty datum-attached sketch must sit on the datum \
                 (origin [0,0,5]), not the world origin [0,0,0]; got {:?}",
                plane.origin
            );
        }
        assert!(
            (plane.z_axis[2] - 1.0).abs() < 1e-9,
            "live plane normal stays +Z; got {:?}",
            plane.z_axis
        );
    }
}