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