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}