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