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 = self.run_plugin_scene(&request)?;
17 self.dirty = true;
18 Ok(with_approximations(
19 with_refusals(
20 serde_json::json!({
21 "featureErrors": report.feature_errors,
22 "featureNotes": report.feature_notes,
23 "featureFulfilment": fulfilment_map(&report),
24 "unresolved": report.unresolved,
25 "displayErrors": report.display_errors,
26 }),
27 &report,
28 ),
29 &report,
30 )
31 .to_string())
32 }
33
34 /// (Re)run the current rolled-to prefix of the engine's history through the
35 /// SAME kernel pipeline and reconcile the display scene. Stores + returns the
36 /// build report JSON.
37 pub(super) fn rerun_history(&mut self) -> String {
38 self.rerun_history_forcing(None)
39 }
40
41 pub(super) fn rerun_history_forcing(&mut self, feature_id: Option<&str>) -> String {
42 if self.defer_plugin_run() { return self.history_report.clone(); }
43 // This run publishes whatever a symbol edit did to the PORT features,
44 // so `pump` need not run again for it.
45 self.history.take_ports_followed();
46 // Keep the incremental cache when rolling or editing: producer entries
47 // own their handles, so rolling before a consuming boolean can reuse its
48 // inputs. Document switches clear the cache in `set_history_json`.
49 // Tag each submission with a generation; `pump` applies completed deltas
50 // immediately for the inline runner or on a later frame for workers.
51 // A transient preview replaces what the run evaluates, in the request
52 // only: the history keeps its own source.
53 self.drop_stale_expression_preview();
54 let request_value = self.run_request_value();
55 match serde_json::from_value::<HistoryRequest>(request_value) {
56 Ok(mut request) => {
57 if let Some(id) = feature_id {
58 request.force_rebuild.push(id.to_string());
59 }
60 // Carry the live display LOD to the runner (the request is the run
61 // boundary the thread/worker receives). The runner re-tessellates
62 // every resident mesh when this differs from its last run's lod.
63 request.display_lod = self.settings.lod_factor;
64 self.run_generation += 1;
65 // A new run supersedes a cancelled one's notice.
66 self.cancelled_run = None;
67 // The PARTS-LIBRARY CHANNEL. A background runner (native
68 // thread / browser worker) owns its own kernel store, so it
69 // needs the library — but sending it WITH every run meant
70 // stringifying every embedded part payload on the UI thread
71 // for every edit, which froze the browser on an imported STEP
72 // assembly. It is sent only when it CHANGES; `fetch` does not
73 // run in the steady state. Inline shares this thread's store
74 // and no-ops.
75 self.runner.sync_parts_library(
76 brep_kernel::parts_library_revision(),
77 &mut brep_kernel::parts_library_map,
78 );
79 self.sync_plugin_runner();
80 self.runner.submit_run(request, self.run_generation);
81 }
82 // A parse failure runs nothing: clear the surfaced frames/profiles (so
83 // stale construction geometry does not linger — the scene keeps its
84 // previous solids) and set an error report, then run the shared
85 // post-apply tail synchronously (no kernel work), so this branch shares
86 // the dirty/gizmo/overlay continuation verbatim with a real apply.
87 Err(error) => {
88 self.construction_frames.clear();
89 self.sketch_profiles.clear();
90 self.sketch_paths.clear();
91 self.sketch_points.clear();
92 self.sketch_axes.clear();
93 self.wire_harness_report = None;
94 self.ports_report = None;
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` 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 self.pump_with(false);
115 }
116
117 /// [`pump`](Self::pump) leaving a run reply, and a refusal asking for the
118 /// parts library, for a later pump: everything else drains. For a
119 /// background document whose library is not installed this frame (see
120 /// [`run_landing`](Self::run_landing)); a reply that arrives meanwhile
121 /// waits for the next frame, which installs the library first.
122 pub fn pump_holding_runs(&mut self) {
123 self.pump_with(true);
124 }
125
126 fn pump_with(&mut self, hold_runs: bool) {
127 self.history.begin_frame();
128 // A user edit refused because an agent's staged feature is evaluating
129 // is said, once per refusal, rather than silently dropped.
130 if self.history.busy_refused() > self.busy_refused_seen {
131 self.busy_refused_seen = self.history.busy_refused();
132 self.push_notice(format!(
133 "Not applied: {}. Try again when it has finished.",
134 self.history.busy().unwrap_or("an agent's change to this document is still evaluating")
135 ));
136 }
137 self.pump_plugin_installations();
138 // The runner REFUSED a run because its resident parts library could not
139 // serve it (see `Reply::NeedPartsLibrary`). It has already forgotten
140 // its copy, so re-running re-installs the library and re-submits. This
141 // is a real path, not just a tripwire: the kernel's orphan GC drops
142 // entries at the end of every run, so an undo to zero components empties
143 // the RUNNER's store while this side's (which never ran) keeps
144 // everything — no revision bookkeeping can see that, only the runner's
145 // content preflight can. `library_resync` breaks the rerun→pump→rerun
146 // recursion (the reinstall makes the second attempt succeed, but a
147 // guard beats relying on that). The guard is tested FIRST because
148 // `poll_library_request` CONSUMES the flag — polling it while a resync
149 // is already in flight would swallow a second refusal.
150 if !hold_runs && !self.library_resync && self.runner.poll_library_request() {
151 self.library_resync = true;
152 self.rerun_history();
153 self.library_resync = false;
154 }
155 // A symbol edit relabelled, added or removed a PORT feature. The eCAD
156 // editors write their blocks straight into the history, which does not
157 // run it, so the run happens here, on the next frame, for every host.
158 if self.history.take_ports_followed() {
159 self.rerun_history();
160 }
161 while let Some(reply) = self.runner.poll_mesh_import() {
162 // A document switch clears this set. Ignore any older reconstruction
163 // reply that was already running when its Reset crossed the queue.
164 let Some(destination) = self.pending_mesh_imports.remove(&reply.id) else {
165 continue;
166 };
167 if destination == MeshImportDestination::Preview {
168 self.mesh_preview_results.push_back(reply);
169 continue;
170 }
171 match reply.result {
172 Ok(output) => match self.import_step_feature(&output.step_text) {
173 Ok(_) => {
174 self.push_notice(
175 "RANSAC reconstruction complete; building imported CAD body",
176 );
177 // A body the chain refused is left out of the model;
178 // say so, by name, rather than let it vanish.
179 if let Some(notice) = crate::runner::refused_bodies_notice(&output.report)
180 {
181 self.push_notice(notice);
182 }
183 }
184 Err(error) => self.push_notice(format!("mesh import failed: {error}")),
185 },
186 Err(error) => self.push_notice(format!("mesh import failed: {error}")),
187 }
188 }
189 // STEP probes: the parse ran on the runner; stash the structure it
190 // found (the import consumes it) and queue the outcome for the panel.
191 while let Some(reply) = self.runner.poll_step_probe() {
192 if !self.pending_step_probes.remove(&reply.id) {
193 continue; // cancelled, or a document switch — nobody is waiting
194 }
195 let outcome = match reply.result {
196 Ok(Some(assembly)) => {
197 let probe = super::model_io::probe_counts(&assembly);
198 // The kernel already refuses a structure that reaches no
199 // geometry, so this is belt-and-braces: an assembly with
200 // zero instances would import as zero components, which
201 // is the silent failure the structured lane forbids.
202 if probe.instances == 0 {
203 super::StepProbeOutcome::Flat
204 } else {
205 self.pending_step_assembly = Some(assembly);
206 super::StepProbeOutcome::Structure(probe)
207 }
208 }
209 Ok(None) => super::StepProbeOutcome::Flat,
210 Err(error) => super::StepProbeOutcome::Failed(error),
211 };
212 self.step_probe_results.push_back((reply.id, outcome));
213 }
214 // The in-flight run's latest progress report (a background runner
215 // posts one before each feature it executes). Kept only for a run
216 // newer than the applied one: a report from a superseded run — or one
217 // still arriving after a cancel bumped the generations — is stale.
218 if let Some(progress) = self.runner.poll_progress() {
219 if progress.generation > self.applied_generation {
220 self.run_progress = Some(progress);
221 }
222 }
223 self.pump_plugin_callbacks();
224 while let Some(reply) = if hold_runs { None } else { self.runner.poll_run() } {
225 self.runs_replied += 1;
226 if reply.generation >= self.run_generation && self.accept_plugin_run(&reply) {
227 self.applied_generation = reply.generation;
228 self.apply_run_output(reply.output);
229 } else {
230 // Superseded — but the runner's baseline has moved past every
231 // display in it, so a later keep can name one of them. Hold
232 // them for that keep (see `superseded_displays`).
233 for (name, _handle, maybe) in reply.output.snapshot {
234 if let Some(display) = maybe {
235 self.superseded_displays.insert(name, display);
236 }
237 }
238 }
239 }
240 // The run just applied released sketch links the kernel could not
241 // resolve, editing the history after the fact: run it again so the
242 // report matches the document. Once, since the second run finds no
243 // listed edge left to release (the inline runner applies it before
244 // this returns; a background runner lands it on a later frame).
245 if std::mem::take(&mut self.sketch_links_released) {
246 self.rerun_history();
247 }
248 // A component replace classifies its mates once its run has landed.
249 if self.replace_pending.is_some() {
250 self.finish_component_replace();
251 }
252 if !self.run_pending() {
253 self.run_progress = None;
254 }
255 // Deferred one-shot framing (Import / Open): frame the scene the moment the
256 // run they submitted has fully landed. Consumed unconditionally once the run
257 // is no longer pending — even when it produced no solids (bbox empty →
258 // `zoom_to_fit` no-ops) — so a later unrelated run never inherits a stale fit.
259 if self.pending_fit && !self.run_pending() {
260 self.pending_fit = false;
261 self.zoom_to_fit();
262 }
263 if self.instructions_wizard_pending && !self.run_pending() {
264 self.instructions_wizard_pending = false;
265 if let Err(error) = self.instructions_run_wizard() {
266 self.push_notice(format!("The instructions wizard could not run: {error}"));
267 }
268 }
269 if self.instructions_views_pending && !self.run_pending() {
270 self.instructions_views_pending = false;
271 if let Err(error) = self.instructions_refresh_views() {
272 self.push_notice(format!("The re-targeted steps' views could not be made: {error}"));
273 }
274 }
275 // Drain any completed measurement replies too (a background runner surfaces
276 // them a frame after selection); for Inline this is a no-op each frame since
277 // `object_info_json` already pumped its own query same-call.
278 self.pump_queries();
279 self.pump_topology();
280 self.pump_sheet_lines();
281 // The CAM generation queue: progress and replies, then the next job.
282 self.cam_pump();
283 }
284
285 /// Ask the runner for the B-rep topology of the displayed solids `names`
286 /// that the scene does not hold for their current handle and that have not
287 /// been asked for since the last applied run, then drain whatever has
288 /// already answered (all of it, for the synchronous Inline runner). The
289 /// drawing sheet's door, and the balloon bubble drag's (which re-projects
290 /// the arrow head on the exact solid, `pmi_reproject_balloon_head`):
291 /// nothing else needs topology on this side, so a document with neither a
292 /// sheet nor a dragged balloon never asks.
293 pub fn request_exact_solids<'a>(&mut self, names: impl IntoIterator<Item = &'a str>) {
294 let mut wanted: Vec<(String, u32)> = Vec::new();
295 for name in names {
296 let Some(display) = self.scene.solid(name) else { continue };
297 let handle = display.source_handle;
298 if handle == 0 || display.is_sketch || self.scene.exact_solid(name).is_some() {
299 continue;
300 }
301 if self.topology_asked.insert(handle) {
302 wanted.push((name.to_string(), handle));
303 }
304 }
305 if !wanted.is_empty() {
306 self.next_topology_id += 1;
307 let id = self.next_topology_id;
308 self.pending_topology.insert(id);
309 self.runner.submit_topology(crate::runner::TopologyRequest { id, solids: wanted });
310 }
311 self.pump_topology();
312 }
313
314 /// Fold every answered topology request into the scene. A reply for a
315 /// display that has moved on to another handle is dropped; the next
316 /// projection asks for the new one.
317 pub fn pump_topology(&mut self) {
318 let mut landed = false;
319 while let Some(reply) = self.runner.poll_topology() {
320 if !self.pending_topology.remove(&reply.id) {
321 continue; // cancelled, or a document switch
322 }
323 for (name, handle, solid) in reply.solids {
324 self.scene.set_exact_solid(&name, handle, solid);
325 }
326 landed = true;
327 }
328 if landed {
329 self.pmi_refresh_stale_balloon();
330 }
331 }
332
333 /// Whether a topology request is still in flight — the frame loop and the
334 /// idle contract wait on it like a measurement query.
335 pub fn topology_pending(&self) -> bool {
336 !self.pending_topology.is_empty()
337 }
338
339 /// Whether a measurement query is still in flight (its reply not yet drained) —
340 /// the query analogue of [`run_pending`](Self::run_pending), so the app keeps the
341 /// frame loop alive until a background runner's measurement lands and displays.
342 /// Always `false` for the synchronous Inline runner.
343 pub fn queries_pending(&self) -> bool {
344 !self.pending_query.is_empty()
345 }
346
347 /// Whether RANSAC reconstruction is still executing on the native runner
348 /// thread or browser worker.
349 pub fn mesh_imports_pending(&self) -> bool {
350 !self.pending_mesh_imports.is_empty()
351 }
352
353 /// Whether the run in flight has something to land this frame: its reply,
354 /// a refusal to re-submit, or plugin work (which is not told apart). A
355 /// background document's [`pump`](Self::pump) needs its own parts
356 /// library installed only then.
357 pub fn run_landing(&mut self) -> bool {
358 self.run_pending() && (self.plugin_work_pending() || self.runner.run_reply_waiting())
359 }
360
361 /// Whether a submitted run has not yet been applied (`run_generation !=
362 /// applied_generation`). Always `false` for the synchronous Inline runner
363 /// (submit → immediate `pump` keeps the two in lockstep); a background runner
364 /// uses it to keep the frame loop alive until its reply lands.
365 pub fn run_pending(&self) -> bool {
366 self.run_generation != self.applied_generation || self.plugin_work_pending()
367 }
368
369 /// The generation of the last APPLIED run — bumps once per applied history
370 /// run (document loads, edits, constraint mutations, solves). A cheap
371 /// staleness key for app-side caches derived from the applied document
372 /// (the update-components outdated badge keys on it).
373 pub fn applied_generation(&self) -> u64 {
374 self.applied_generation
375 }
376
377 /// What the in-flight run is executing right now, as far as the runner
378 /// has reported (see [`crate::runner::RunProgress`]); `None` when nothing
379 /// is running or the run has not reached its first executed feature.
380 pub fn run_progress(&self) -> Option<&crate::runner::RunProgress> {
381 self.run_progress.as_ref()
382 }
383
384 /// The feature id the last cancelled run was executing (empty when it was
385 /// cancelled before any progress arrived), until the next submit.
386 pub fn cancelled_run(&self) -> Option<&str> {
387 self.cancelled_run.as_deref()
388 }
389
390 /// CANCEL the in-flight run. The runner abandons its work and comes back
391 /// with an EMPTY resident registry (a fresh thread / a fresh worker — see
392 /// [`crate::runner::HistoryRunner::cancel`]), so this side forgets
393 /// everything that was waiting on it: the run itself (generations are
394 /// bumped past it, so a straggling reply from the old runner is dropped
395 /// as stale), pending measurement queries, document-bound mesh imports
396 /// and a deferred fit. The DISPLAY SCENE is left as the last applied run
397 /// built it — the document is ahead of it now, which the notice says; the
398 /// next edit re-runs the whole history through the new runner (a cold
399 /// run: the warm cache went with the old one). Nothing inside a feature
400 /// is interruptible, so the native thread keeps burning CPU until the
401 /// feature it is on finishes; the browser worker is terminated outright.
402 ///
403 /// `false` when nothing was running, or the runner cannot abandon (the
404 /// synchronous Inline runner, whose runs are over before anyone can ask).
405 pub fn cancel_run(&mut self) -> bool {
406 self.cancel_run_with_notice(true)
407 }
408
409 pub(super) fn cancel_run_with_notice(&mut self, notify: bool) -> bool {
410 if self.plugin_installation_pending() { return false; }
411 if self.cancel_plugin_action() { return true; }
412 if !self.run_pending() || !self.runner.cancel() {
413 return false;
414 }
415 let stalled_on = self.run_progress.take().map(|progress| progress.feature_id);
416 // Past every generation submitted so far: a reply the old runner
417 // already posted (sitting in the main event loop on wasm) carries an
418 // older number than this and is dropped by `pump`'s gate.
419 self.run_generation += 1;
420 self.applied_generation = self.run_generation;
421 self.pending_query.clear();
422 // The new runner's registry counts handles from one again, so every
423 // topology keyed by an old handle could alias a new display.
424 self.pending_topology.clear();
425 self.topology_asked.clear();
426 self.scene.clear_exact_solids();
427 self.sheet_lines.forget();
428 // The old runner's held displays name ITS handles; the new one's
429 // first run emits every display afresh.
430 self.superseded_displays.clear();
431 let dropped_imports = self.pending_mesh_imports.len();
432 self.pending_mesh_imports.clear();
433 let dropped_probes = self.pending_step_probes.len();
434 self.pending_step_probes.clear();
435 self.pending_fit = false;
436 self.instructions_wizard_pending = false;
437 self.instructions_views_pending = false;
438 self.library_resync = false;
439 self.sketch_links_released = false;
440 self.cancelled_run = Some(stalled_on.clone().unwrap_or_default());
441 if notify {
442 self.push_notice(match stalled_on {
443 Some(id) if !id.is_empty() => format!(
444 "Run cancelled while executing {id}. The model shows the last completed \
445 result; edit or delete the feature to rebuild."
446 ),
447 _ => "Run cancelled. The model shows the last completed result; the next edit \
448 rebuilds it."
449 .to_string(),
450 });
451 }
452 if dropped_imports > 0 {
453 self.push_notice(format!(
454 "cancelled {dropped_imports} pending mesh import{}",
455 if dropped_imports == 1 { "" } else { "s" }
456 ));
457 }
458 if dropped_probes > 0 {
459 self.push_notice("cancelled the STEP file being read — upload it again to import it");
460 }
461 true
462 }
463
464 /// How many run replies the runner has sent back (see `runs_replied`).
465 pub fn runs_replied(&self) -> u64 {
466 self.runs_replied
467 }
468
469 /// Whether the display scene currently holds at least one solid. Used by the
470 /// app's async-safe first-frame framing: under a background runner (thread /
471 /// worker) the seed run lands a frame (or many) after boot, so the shell waits
472 /// for `has_solids() && !run_pending()` before its one-shot `zoom_to_fit`.
473 pub fn has_solids(&self) -> bool {
474 !self.scene.solids().is_empty()
475 }
476
477 /// Swap in a different history runner (the platform injects its own — the native
478 /// app installs a [`ThreadRunner`](crate::runner::ThreadRunner); wasm keeps the
479 /// default Inline until M3's worker). Resets the new runner's delta baseline so
480 /// the next run rebuilds fully. Call BEFORE seeding a document so the seed builds
481 /// through the installed runner.
482 pub fn set_runner(&mut self, runner: Box<dyn crate::runner::HistoryRunner>) {
483 self.runner = runner;
484 self.plugin_runner_replaced();
485 self.runner.reset();
486 self.superseded_displays.clear();
487 self.pending_mesh_imports.clear();
488 self.mesh_preview_results.clear();
489 self.pending_topology.clear();
490 self.topology_asked.clear();
491 self.scene.clear_exact_solids();
492 self.sheet_lines.forget();
493 }
494
495 /// Apply a [`SceneRunner`](crate::pipeline::SceneRunner) delta to the display
496 /// scene and build the history report JSON — the APPLY half of the M2a seam.
497 ///
498 /// Reconcile preserving ORDER + reuse: MOVE every current display out of the
499 /// scene ([`RenderScene::drain`](crate::scene::RenderScene::drain)) into a
500 /// name-keyed `kept` map, then reinsert in snapshot order — a fresh entry
501 /// (`Some`) replaces, an UNCHANGED entry (`None`) reuses its moved-out display
502 /// (stable `revision` ⇒ GPU-buffer reuse, Task-1; its `source_handle` equals
503 /// the run's handle by the monotonic-handle reuse invariant). Leftovers in
504 /// `kept` — departed kernel solids AND the previous run's sketch sheets — are
505 /// dropped; `refresh_committed_sketches` (run in the shared continuation after)
506 /// re-adds the sheets, so dropping them here is correct.
507 ///
508 /// Then the report continuation (identical to the pre-seam run): keep the run's
509 /// resolved frames + solved sketch profiles and fold the per-feature timings /
510 /// output-names into the id-keyed report JSON, then hand it to
511 /// [`finish_apply`](Self::finish_apply) — the shared dirty/gizmo/overlay tail
512 /// that the parse-error branch in [`rerun_history`](Self::rerun_history) also
513 /// calls, so both paths share the continuation verbatim.
514 pub(super) fn apply_run_output(&mut self, output: crate::pipeline::RunOutput) {
515 let crate::pipeline::RunOutput {
516 snapshot,
517 mut report,
518 provenance,
519 entity_origin,
520 assembly_poses,
521 assembly_fixed,
522 moved_solids,
523 imported_colors,
524 consumed,
525 assembly,
526 } = output;
527 for (id, data) in &report.plugin_persistent_data {
528 self.history.fold_plugin_persistent_data(id, data.clone());
529 }
530 // The runner already forced fresh displays for the solver-moved solids
531 // (their snapshot entries arrive `Some`); nothing extra to do main-side.
532 let _ = moved_solids;
533 // Un-pose the active PMI view's exploded displays BEFORE the reconcile
534 // keeps them, so the re-pose after apply starts from the modeling
535 // pose and never compounds. A live component drag's poses go first
536 // (they were applied last).
537 self.component_live_restore();
538 self.pmi_restore_explode();
539 // A motion study's posed frame goes too (re-posed after apply).
540 self.simulation_restore_displays();
541
542 // The names this run consumed, for the committed-sketch refresh in the
543 // tail below — the runner folded them, so the UI thread does not execute
544 // the history a second time to fold them again.
545 self.consumed_names = consumed.into_iter().collect();
546 // Adopt the run's assembly tail. The PARTS LIBRARY comes out of it here
547 // rather than in `adopt_assembly_sync`, so the (potentially megabyte)
548 // store is installed and mirrored into the document exactly once, on the
549 // run that moved it, and never kept in the snapshot afterwards.
550 self.assembly_sync = assembly.map(|mut sync| {
551 if let Some(library) = sync.parts_library.take() {
552 // CLEAR, then install. `install_parts_library` keeps a resident
553 // entry whose identity (source key + signature + document hash)
554 // matches the incoming one VERBATIM — the right rule for the
555 // main → runner direction it was written for, because the
556 // runner may hold a heal the sender never saw. This is the
557 // other direction: the run that just healed an entry (a heal
558 // rewrites the snapshot and deliberately does NOT change the
559 // identity) is the one holding the newer copy, so keeping the
560 // resident one would silently drop it and the document would
561 // re-heal on every open. Emptying first makes every incoming
562 // entry a fresh insert, i.e. "the store IS what the run left".
563 brep_kernel::install_parts_library(&Default::default());
564 brep_kernel::install_parts_library(&library);
565 if let Ok(block) = serde_json::to_value(&library) {
566 self.history.set_parts_library(block);
567 }
568 }
569 sync
570 });
571
572 // --- assembly pose-authority fold -------- Adopt the solver's pose /
573 // isFixed write-backs into the owning ACOMP features by
574 // `inputParams.id` BEFORE anything persists or re-runs this document.
575 // Deliberately NOT `update_feature_params`: the fold must not mint an
576 // undo entry nor trigger a rerun (rerun → solve → fold → rerun would
577 // loop); `fold_param_no_undo` writes the param silently, and the next
578 // run's request simply carries the solved pose (a no-motion solve emits
579 // no updates, so fingerprints never churn).
580 for (id, pose) in assembly_poses {
581 self.fold_assembly_pose(&id, pose);
582 }
583 for (id, fixed) in assembly_fixed {
584 self.history
585 .fold_param_no_undo(&id, "isFixed", serde_json::Value::Bool(fixed));
586 }
587
588 // Adopt the run's eager provenance (SOLID last-writer) + entity origin
589 // (face/edge first-writer) wholesale (both drive `creating_feature` + the
590 // Info tab's `creatingFeature` with no cold re-run), and INVALIDATE the
591 // object-info measurement cache + any in-flight query: the geometry changed,
592 // so cached measurements are stale and a pending reply is superseded (a
593 // re-selection re-queries against the fresh geometry).
594 self.provenance = provenance.into_iter().collect();
595 self.entity_origin = entity_origin.into_iter().collect();
596 self.info_cache.clear();
597 self.pending_query.clear();
598
599 // Fold the run's IMPORTED COLOURS into the engine's own metadata store —
600 // the seam between the kernel's (thread-local, never persisted by us)
601 // name-keyed store and the one the Info window edits and the document
602 // saves. NON-overwriting on purpose: an import re-stamps its colour on
603 // every replay, and a colour the user changed in the panel must win.
604 for (name, hex) in imported_colors {
605 if self.metadata.attribute(&name, "color").is_none() {
606 self.metadata.set_attribute(&name, "color", &hex);
607 }
608 }
609
610 // Reconcile the scene: move current displays out, reinsert in order.
611 let mut kept: std::collections::HashMap<String, crate::scene::SolidDisplay> = self
612 .scene
613 .drain()
614 .into_iter()
615 .map(|solid| (solid.name.clone(), solid))
616 .collect();
617 for (name, handle, maybe) in snapshot {
618 match maybe {
619 Some(display) => {
620 self.superseded_displays.remove(&name);
621 self.scene.insert_solid(display);
622 }
623 // "Unchanged": the runner last emitted `handle` for this name.
624 // That emit is the scene's display when its reply was applied,
625 // and a held superseded display when it was dropped — the
626 // handle says which, since handles are monotonic and never
627 // recycled within one runner. Neither holding it is a protocol
628 // breach (a reply lost between the runner and `pump`), and the
629 // app is not the place to crash on one, in any build: the
630 // solid is left out of this frame and the report names it.
631 None => {
632 let kept_display = kept.remove(&name);
633 let display = match kept_display {
634 Some(display) if display.source_handle == handle => Some(display),
635 _ => self
636 .superseded_displays
637 .remove(&name)
638 .filter(|display| display.source_handle == handle),
639 };
640 match display {
641 Some(display) => self.scene.insert_solid(display),
642 None => {
643 report.display_errors.push(format!(
644 "{name}: the run kept resident handle {handle}, which no reply has displayed"
645 ));
646 }
647 }
648 }
649 }
650 }
651 // Topology is kept only for a display whose handle survived the run,
652 // and so is the record of what was asked: a surviving handle is in
653 // flight, answered or not in the registry, and every other is gone.
654 self.scene.prune_exact_solids();
655 let live: std::collections::HashSet<u32> =
656 self.scene.solids().iter().map(|solid| solid.source_handle).collect();
657 self.topology_asked.retain(|handle| live.contains(handle));
658
659 // Keep every plane frame the run resolved (DATUM/PLANE/SKETCH);
660 // `refresh_construction_datums` filters to the D/P producers.
661 self.construction_frames = report.frames.clone();
662 // Keep every solved sketch profile so `refresh_committed_sketches` can
663 // synthesize the committed sketch sheet solids.
664 self.sketch_profiles = report.profiles.clone();
665 // ...and every path chain, so a sketch whose geometry closes NO region (an
666 // open chain — the reported single line) still has something to draw.
667 self.sketch_paths = report.paths.clone();
668 // ...and every published point, so a sketch holding ONLY points (a
669 // hole-placement sketch) still has something to draw.
670 self.sketch_points = report.points.clone();
671 // Keep every axis line the run published so the angle gizmo can resolve a
672 // revolve `axis` reference to a world line without re-running.
673 self.sketch_axes = report.axes.clone();
674 // ...and the wire-harness routing report, for the harness panel.
675 self.wire_harness_report = report.wire_harness.clone();
676 // ...and the ports tail's report, for the Qualify panel: the tail is not
677 // a feature, so nothing else carries what it refused or could not resolve.
678 self.ports_report = report.ports.clone();
679 // ...and the PMI report (every view's resolved annotations).
680 self.pmi_report = report.pmi.clone();
681 self.fold_plugin_annotation_data();
682 // Fold the per-feature timing / output-name pairs into id-keyed maps so the
683 // history-tree UI can look them up by feature id.
684 let timings: serde_json::Map<String, serde_json::Value> = report
685 .feature_timings
686 .iter()
687 .map(|(id, ms)| (id.clone(), serde_json::json!(ms)))
688 .collect();
689 let outputs: serde_json::Map<String, serde_json::Value> = report
690 .feature_outputs
691 .iter()
692 .map(|(id, names)| (id.clone(), serde_json::json!(names)))
693 .collect();
694 let report_json = with_approximations(
695 with_refusals(
696 serde_json::json!({
697 "featureErrors": report.feature_errors,
698 "featureNotes": report.feature_notes,
699 "featureFulfilment": fulfilment_map(&report),
700 "unresolved": report.unresolved,
701 "displayErrors": report.display_errors,
702 "featureTimings": timings,
703 "featureOutputs": outputs,
704 }),
705 &report,
706 ),
707 &report,
708 )
709 .to_string();
710 self.release_unresolved_sketch_links(&report.unresolved);
711 self.finish_apply(report_json);
712 }
713
714 /// Release every sketch edge link whose edge this run could not resolve —
715 /// the kernel's own `unresolved` word, per feature, so an edge a LATER
716 /// feature consumed (gone from the final scene, present where the sketch
717 /// runs) is never mistaken for a lost one. For each such link the sketch
718 /// keeps its geometry as ordinary fixed construction geometry
719 /// ([`crate::sketch::external_ref::release_ref_json`]), drops the
720 /// `externalRefs` entry and takes the name out of `projectedEdges`. A
721 /// listed name that never materialized has nothing to keep and stays
722 /// listed (the report keeps naming it).
723 ///
724 /// Folded like the assembly pose write-back: no undo checkpoint, so undoing
725 /// the deletion that lost the edge restores the snapshot with the link
726 /// intact, and no run from here (re-entrancy); the flag makes `pump` run
727 /// once more. Nothing happens while a sketch is being edited (the editor
728 /// owns its doc and the rolled-back run never runs it) or while the
729 /// document is locked (a refused fold would be retried every run).
730 fn release_unresolved_sketch_links(&mut self, unresolved: &[String]) {
731 if unresolved.is_empty() || self.sketch_edit.is_some() || self.history.locked().is_some() {
732 return;
733 }
734 let mut released_any = false;
735 for index in 0..self.history.len() {
736 if !self.history.feature_type(index).is_some_and(|t| matches!(t.as_str(), "S" | "SKETCH")) {
737 continue;
738 }
739 let Some(id) = self.history.feature_id(index).filter(|id| !id.is_empty()) else {
740 continue;
741 };
742 let Some(mut persistent) = self.history.feature_persistent_data(index) else {
743 continue;
744 };
745 let mut refs: Vec<crate::sketch::ExternalRef> = persistent
746 .get("externalRefs")
747 .and_then(|v| serde_json::from_value(v.clone()).ok())
748 .unwrap_or_default();
749 if refs.is_empty() {
750 continue;
751 }
752 // `"{id}: {name}"`, possibly with the kernel's rename hint appended.
753 let lost: Vec<String> = refs
754 .iter()
755 .map(|r| r.edge_name.clone())
756 .filter(|name| {
757 let line = format!("{id}: {name}");
758 unresolved
759 .iter()
760 .any(|u| *u == line || u.starts_with(&format!("{line} (renamed:")))
761 })
762 .collect();
763 if lost.is_empty() {
764 continue;
765 }
766 let mut sketch = persistent.get("sketch").cloned().unwrap_or(serde_json::Value::Null);
767 for r in refs.iter().filter(|r| lost.contains(&r.edge_name)) {
768 crate::sketch::external_ref::release_ref_json(&mut sketch, r);
769 }
770 refs.retain(|r| !lost.contains(&r.edge_name));
771 if let Some(object) = persistent.as_object_mut() {
772 if !sketch.is_null() {
773 object.insert("sketch".into(), sketch);
774 }
775 object.insert(
776 "externalRefs".into(),
777 serde_json::to_value(&refs).unwrap_or_else(|_| serde_json::Value::Array(Vec::new())),
778 );
779 }
780 let listed = brep_kernel::reference_names(
781 self.history.feature_params(index).as_ref().and_then(|p| p.get("projectedEdges")),
782 );
783 let kept: Vec<serde_json::Value> = listed
784 .into_iter()
785 .filter(|name| !lost.contains(name))
786 .map(serde_json::Value::String)
787 .collect();
788 self.history.fold_plugin_persistent_data(&id, persistent);
789 self.history.fold_param_no_undo(&id, "projectedEdges", serde_json::Value::Array(kept));
790 released_any = true;
791 }
792 if released_any {
793 self.sketch_links_released = true;
794 }
795 }
796
797 /// The shared post-apply TAIL: mark dirty, store the report JSON, re-sync an
798 /// armed gizmo, and rebuild the persistent committed-sketch + construction-datum
799 /// overlays. Called after a real run's [`apply_run_output`](Self::apply_run_output)
800 /// AND from [`rerun_history`](Self::rerun_history)'s parse-error branch, so both
801 /// paths run the identical continuation. Callers read the result via
802 /// [`Self::history_report`](Self::history_report_json).
803 fn finish_apply(&mut self, report_json: String) {
804 self.dirty = true;
805 self.history_report = report_json;
806 // Keep an armed gizmo glued to its feature as the model rebuilds. During a
807 // transform drag the re-sync is driven by `transform_drag_to` itself (which
808 // resolves the delta against the frozen grab frame first, then syncs), so
809 // skip it here to avoid a redundant double-feed per drag frame. Transform
810 // mode re-feeds the widget frame; dimension mode re-projects the annotation
811 // leaders onto the rebuilt (param-changed) geometry.
812 if self.transform_gizmo.drag.is_none() {
813 match self.transform_gizmo.mode {
814 GizmoMode::Transform => self.sync_transform_gizmo(),
815 GizmoMode::Dimension => self.refresh_feature_dimension_overlay(),
816 GizmoMode::None => {}
817 }
818 }
819 // Rebuild the persistent committed-sketch overlays against the reconciled
820 // scene (also covers `set_history_json`, which returns this call's result).
821 self.refresh_committed_sketches();
822 // Rebuild the persistent construction datum/plane overlays from the frames
823 // the run just surfaced (D/P features only; sketches render as curves).
824 self.refresh_construction_datums();
825 // Rebuild the BOARD's 3D bodies from the `pcb` block, for the same reason
826 // the sketch sheets are rebuilt here: the reconcile above kept only the
827 // run's own outputs. A document with no `pcb` block returns immediately
828 // inside.
829 self.refresh_board_geometry();
830 // Assembly documents: adopt the component projection and the solved
831 // constraint state the runner shipped, and fold that state back into the
832 // document (the pose-authority contract — see `assembly_ops`).
833 // Componentless documents return immediately inside.
834 self.adopt_assembly_sync();
835 // Component selection needs the new member projection above, not the
836 // previous run's assembly. This also completes a deferred Move Copy.
837 // Keep an armed COMPONENT Move gizmo glued to its (possibly re-solved)
838 // component: re-anchor at the fresh member bbox. Never during its own
839 // drag — the drag feed owns the widget frame (free-move live-follow).
840 if self.component_move.drag.is_none() {
841 self.component_move_sync();
842 }
843 // Rebuild the assembly-constraint viewport overlays from the rows the
844 // same reply carried (an inert no-op — empty group — for a document with
845 // no assembly state). ORDER MATTERS: the overlay read must follow the
846 // adopt.
847 self.refresh_constraint_overlay();
848 // PMI: re-pose the active view's exploded solids on the fresh displays
849 // and re-bake its annotation overlay from the run's report.
850 self.pmi_after_apply();
851 // A motion study being played or scrubbed: its frame back on the
852 // fresh displays (or a re-bake if the document moved).
853 self.simulation_after_apply();
854 // Re-derive every display colour from the metadata store, LAST — after
855 // the sketch sheets, the datum overlays and the assembly sync have all
856 // settled the scene, so no display inserted above is missed. This is why
857 // a model colour now survives a feature edit: the freshly tessellated
858 // display arrives colourless and is re-coloured from the store, instead
859 // of the colour living only on the display that was just thrown away.
860 // A no-op when nothing changed, which is the common case.
861 self.sync_colors_from_metadata();
862 }
863
864 /// Load a whole history document (a saved part file parses as one); the
865 /// engine now OWNS this recipe. Rolls to the last feature and builds it.
866 ///
867 /// The document's top-level `metadata` field (the Properties-panel
868 /// name-keyed store) is lifted out into [`Self::metadata`] before the feature
869 /// list is handed to the kernel — loading a part REPLACES the store wholesale
870 /// (a document with no `metadata` clears it), mirroring the previous metadata
871 /// manager's load semantics. Round-trips with [`Self::history_request_json`].
872 ///
873 /// The top-level `workbench` field (the ACTIVE-WORKBENCH id the save embedded
874 /// — see [`Self::history_request_json`]) is lifted off the kernel recipe the
875 /// same way and applied through [`Self::apply_settings_json`] — the SAME seam
876 /// the toolbar's workbench dropdown writes through — so the palette / context
877 /// offers / workbench buttons react to a restored workbench exactly as they
878 /// do to a manual switch (settings generation bump included). Tolerances:
879 ///
880 /// * a legacy document WITHOUT the field (or with a non-string value) leaves
881 /// the current workbench untouched — opening an old file never yanks the
882 /// user out of their workbench;
883 /// * an unknown/stale id is stored RAW (never an error): the settings layer
884 /// deliberately doesn't validate ids, and every consumer resolves through
885 /// the app-side registry's `resolve()`, which falls back to the default
886 /// workbench — so a file saved by a build with a workbench this build
887 /// doesn't know still opens cleanly;
888 /// * the restored id is deliberately NOT persisted to the settings blob —
889 /// that blob stays the user's boot preference; a document's workbench is
890 /// session-scoped (the next explicit dropdown change persists as usual).
891 pub fn set_history_json(&mut self, request_json: &str) -> Result<String, String> {
892 // A new document: its toolpaths are not this one's.
893 self.cam.clear();
894 // A locked history (a PLM revision this user may not change) is not
895 // replaced either: a fresh `History` would arrive unlocked, which is the
896 // one door `History::lock` cannot hold by itself. Opening another
897 // document is a new engine, and a reload after checkout unlocks first.
898 if let Some(reason) = self.history.locked() {
899 return Err(format!("this document is read-only: {reason}"));
900 }
901 // A document switch is a wholesale model replacement: drop the incremental
902 // cache so the new model starts from a clean slate (no cross-document
903 // staleness, no unbounded cache growth across many opens). The roll/edit
904 // hot path (`rerun_history`) deliberately KEEPS the cache for instant
905 // rollback; this is the ONE place the full clear belongs.
906 brep_kernel::clear_history_cache();
907 // Reset the delta runner's baseline in lockstep with the cache clear so the
908 // new document is a FULL rebuild (no reuse against the prior model's names).
909 self.runner.reset();
910 self.superseded_displays.clear();
911 self.pending_mesh_imports.clear();
912 self.mesh_preview_results.clear();
913 self.pending_topology.clear();
914 self.topology_asked.clear();
915 self.scene.clear_exact_solids();
916 // A probed-but-unconsumed STEP assembly belongs to the document being
917 // replaced: importing it into the NEW one would land a file the user never
918 // chose here (and hold its solids resident until they did). A probe
919 // still running is likewise the old document's: its answer is dropped.
920 self.pending_step_assembly = None;
921 self.pending_step_probes.clear();
922 self.step_probe_results.clear();
923 // Everything the previous document's run shipped belongs to THAT
924 // document: the assembly tail, the component projection, its consumed
925 // names, and the main-side session's generation stamp. The new
926 // document's first reply replaces them; until it lands, nothing must
927 // read the old one's.
928 self.assembly_sync = None;
929 self.assembly_components.clear();
930 self.assembly_session_generation = None;
931 self.consumed_names.clear();
932 // The open sheet, the open sheet form and the projection cache all
933 // name objects of the document being replaced.
934 self.forget_sheets();
935 // A motion study being played or scrubbed names that document's
936 // studies and solids: its bake and posed displays go with it.
937 self.simulation_forget_playback();
938 // A preview belongs to the document being replaced.
939 self.expression_preview = None;
940 let mut document: serde_json::Value = serde_json::from_str(request_json)
941 .map_err(|error| format!("history parse: {error}"))?;
942 let thumbnail = document.as_object_mut().and_then(|o| o.remove("thumbnail"));
943 self.thumbnail_cache.replace(None);
944 // Pull `metadata` out of the document so the engine holds the single copy
945 // (kept off the History recipe the kernel executes).
946 let metadata_value = document
947 .as_object_mut()
948 .and_then(|object| object.remove("metadata"));
949 self.metadata.load_json(metadata_value.as_ref());
950 // Lift the saved active-workbench id off the kernel recipe (`metadata`'s
951 // sibling — the kernel would ignore the extra field, but the engine owns
952 // it) and apply it through the shared settings seam; see the doc comment
953 // for the legacy/unknown-id tolerances. Applied BEFORE the rebuild below
954 // so anything reading the settings post-run already sees the restored id.
955 if let Some(workbench_id) = document
956 .as_object_mut()
957 .and_then(|object| object.remove("workbench"))
958 .as_ref()
959 .and_then(serde_json::Value::as_str)
960 {
961 let _ = self.apply_settings_json(
962 &serde_json::json!({ "workbench": workbench_id }).to_string(),
963 );
964 }
965 // Stamp each sketch's per-loop ids onto its geometries before the model
966 // is built. Deriving already yields the right ids, so this renames
967 // nothing — it PERSISTS them, which is what lets a loop keep its identity
968 // when the edge the id came from is deleted. Doing it here (rather than
969 // only on sketch commit) covers every document, including one built by a
970 // script that never enters sketch mode. See the kernel's
971 // `features/sketch/loop_ids`.
972 stamp_sketch_loop_ids(&mut document);
973 // SEED this thread's kernel parts library from the document's block.
974 // The per-run request no longer carries the block (see
975 // `History::parts_library`), so this explicit install is the ONE door
976 // that seeds a loaded document — it is what `parts_library_json()`
977 // (SAVE), the mutation-path session re-run and the export lanes all
978 // resolve ACOMPs against, and it bumps the revision so the next run
979 // hands the fresh library to the background runner. A run that heals or
980 // GCs it ships the result back (`apply_run_output`).
981 let library = document
982 .get("partsLibrary")
983 .and_then(|block| serde_json::from_value(block.clone()).ok())
984 .unwrap_or_default();
985 brep_kernel::install_parts_library(&library);
986 self.history = History::from_request_json(&document.to_string())?;
987 self.document_thumbnail.replace(thumbnail.map(|image| (self.history.edit_serial(), image)));
988 Ok(self.rerun_history())
989 }
990
991 /// The whole history request document (persistence / debugging), with the
992 /// engine-owned extras folded back in on top of the kernel recipe so
993 /// save→open round-trips them:
994 ///
995 /// * `metadata` — the Properties-panel store, written only when non-empty so
996 /// an un-annotated model persists as before;
997 /// * `workbench` — the CURRENT active-workbench id
998 /// (`self.settings.workbench`), ALWAYS written so a saved part reopens in
999 /// the workbench it was saved from (restored by
1000 /// [`Self::set_history_json`]). Always-embed keeps the invariant simple:
1001 /// the serialized field tracks the LIVE setting, never a stale stored copy
1002 /// — the load lifts it off the kernel recipe entirely, so this is the ONE
1003 /// place it is (re)written. Note the deliberate consequence: switching
1004 /// workbench changes this document, so the file panel's dirty flag flips —
1005 /// consistent with the `metadata` precedent, and semantically true now
1006 /// that the workbench is part of the saved file.
1007 pub fn history_request_json(&self) -> String {
1008 self.history_request_value().to_string()
1009 }
1010
1011 /// [`Self::history_request_json`] as a value, for a caller that edits the
1012 /// document (Update components): nothing is printed or parsed.
1013 pub fn history_request_value(&self) -> serde_json::Value {
1014 let mut document = self.history.request_value();
1015 if let Some(object) = document.as_object_mut() {
1016 if !self.metadata.is_empty() {
1017 object.insert("metadata".into(), self.metadata.to_json());
1018 }
1019 object.insert("thumbnail".into(), self.model_thumbnail());
1020 object.insert(
1021 "workbench".into(),
1022 serde_json::Value::String(self.settings.workbench.clone()),
1023 );
1024 }
1025 document
1026 }
1027
1028 /// Cache the small portable PNG by scene content, not by camera position.
1029 /// The picture is the DOCUMENT's, so it is rendered only while the scene is
1030 /// the document's model as built. A run the scene has not caught up with
1031 /// (an edit whose rebuild is still to come) gets an explicitly pending,
1032 /// empty image rather than a picture of the previous geometry; plugin work
1033 /// in flight is not that, since a candidate keeps its own scene until it
1034 /// commits. A transient presentation — an expression preview (a family
1035 /// member shown in the document's place) or an illustration — gets the
1036 /// document's own last picture (the one it was opened with, or the one its
1037 /// last save rendered) rather than what is on screen. A just-opened file
1038 /// keeps its embedded image while its unchanged recipe is being rebuilt.
1039 /// The document's picture is keyed by the history's edit serial, which
1040 /// moves on user edits, undo and redo only (a run's write-back ticks the
1041 /// revision). The same built model renders the same bytes every time.
1042 fn model_thumbnail(&self) -> serde_json::Value {
1043 let pending = self.run_generation != self.applied_generation();
1044 let transient = self.expression_preview.is_some() || self.illustration_snapshot.is_some();
1045 let mut key = vec![self.history.revision(), self.applied_generation(),
1046 self.scene.visibility_revision(), self.settings_generation, pending as u64, transient as u64];
1047 key.extend(self.scene.solids().iter().map(|s| s.revision));
1048 if let Some((old, image)) = self.thumbnail_cache.borrow().as_ref() {
1049 if *old == key { return image.clone(); }
1050 }
1051 let image = if pending || transient {
1052 self.document_thumbnail.borrow().as_ref().filter(|(serial, _)| *serial == self.history.edit_serial())
1053 .map(|(_, image)| image.clone())
1054 .unwrap_or_else(|| crate::thumbnail::empty_embedded("pending"))
1055 } else {
1056 let capture = crate::thumbnail::capture(&self.scene);
1057 let image = match crate::thumbnail::render_png(&capture, crate::thumbnail::SIZE) {
1058 Some(png) => crate::thumbnail::embedded_png(&png, crate::thumbnail::SIZE, "ready"),
1059 None => crate::thumbnail::empty_embedded("empty"),
1060 };
1061 self.document_thumbnail.replace(Some((self.history.edit_serial(), image.clone())));
1062 image
1063 };
1064 self.thumbnail_cache.replace(Some((key, image.clone())));
1065 image
1066 }
1067
1068 /// The tree listing `{ step, features:[{index,type,id}] }` for the UI panel.
1069 pub fn history_listing_json(&self) -> String {
1070 self.history.listing_json()
1071 }
1072
1073 /// The last build report JSON.
1074 pub fn history_report_json(&self) -> String {
1075 self.history_report.clone()
1076 }
1077
1078 pub fn history_len(&self) -> usize {
1079 self.history.len()
1080 }
1081
1082 /// The rolled-to (selected) feature index.
1083 pub fn history_rollback(&self) -> usize {
1084 self.history.rollback()
1085 }
1086
1087 /// A monotonic counter of mutation opportunities on the history document —
1088 /// the cache key for anything derived from it. See [`crate::history::History::revision`].
1089 pub fn history_revision(&self) -> u64 {
1090 self.history.revision()
1091 }
1092
1093 pub fn feature_type_at(&self, index: usize) -> Option<String> {
1094 self.history.feature_type(index)
1095 }
1096
1097 pub fn feature_id_at(&self, index: usize) -> Option<String> {
1098 self.history.feature_id(index)
1099 }
1100
1101 /// The `inputParams` document of feature `index` (`"null"` if none) — the
1102 /// dialog's editing-buffer source.
1103 pub fn feature_params_json(&self, index: usize) -> String {
1104 self.history
1105 .feature_params(index)
1106 .map(|v| v.to_string())
1107 .unwrap_or_else(|| "null".to_string())
1108 }
1109
1110 /// Mint the id for a NEW feature: `{base}{N}` where `base` is the feature's
1111 /// shortName ([`crate::features::feature_short_name`]) and `N` is the part
1112 /// history's persistent GLOBAL counter (monotonic, never reused, round-trips
1113 /// save/load — see [`History::next_feature_id`]). `&mut` because the counter
1114 /// advances; if the caller's `add_feature` then fails the number is simply
1115 /// skipped (monotonic-with-gaps is the contract, not an error).
1116 pub fn next_feature_id(&mut self, base: &str) -> String {
1117 self.history.next_feature_id(base)
1118 }
1119
1120 /// Adopt an edited document as one undo step and rebuild through the normal
1121 /// runner, preserving the tab's scene settings and history stack.
1122 ///
1123 /// Callers edit what [`Self::history_request_json`] gave them, which carries
1124 /// the session's `workbench` and `thumbnail`. Those are lifted off, as a load
1125 /// lifts them ([`Self::set_history_json`]): a copy kept inside the history
1126 /// goes stale at the next workbench switch or render and reads as an unsaved
1127 /// change of the document (and a stamped thumbnail made a freshly refreshed
1128 /// tab read dirty, which hid a reload that threw the refresh away).
1129 pub fn edit_document_json(&mut self, document: &str) -> Result<String, String> {
1130 let value: serde_json::Value = serde_json::from_str(document).map_err(|e| e.to_string())?;
1131 self.edit_document(value)
1132 }
1133
1134 /// [`Self::edit_document_json`] for a caller that holds the document as a
1135 /// value: nothing is printed or parsed again (at 500 parts the library's
1136 /// text round trips were most of an Update Components commit).
1137 pub fn edit_document(&mut self, mut value: serde_json::Value) -> Result<String, String> {
1138 use serde::Deserialize;
1139 let library = value.get("partsLibrary").map(brep_kernel::PartsLibraryMap::deserialize)
1140 .transpose().map_err(|e| format!("parts library: {e}"))?;
1141 if let Some(object) = value.as_object_mut() {
1142 object.remove("workbench");
1143 object.remove("thumbnail");
1144 }
1145 // Instructions taking a new state of their assembly re-target in the
1146 // same undo step (refused while one waits for approval).
1147 let retargeted = self.is_instructions() && self.instructions_fold_retarget(&mut value)?;
1148 self.history.adopt_document_value_checkpointed(value)?;
1149 if retargeted {
1150 self.instructions_views_pending = true;
1151 }
1152 // The runner's library channel reads the resident store. Updating only
1153 // History left it serving the old snapshot after Update Components.
1154 if let Some(library) = library {
1155 brep_kernel::install_parts_library(&library);
1156 }
1157 Ok(self.rerun_history())
1158 }
1159
1160 /// Publish derived changes folded into the current user edit, without adding
1161 /// another undo step (for example PCB placement and its assembly pose).
1162 pub fn rebuild_current_history(&mut self) -> String {
1163 self.rerun_history()
1164 }
1165
1166 /// Roll the model to feature `index`: re-run `features[0..=index]`.
1167 pub fn roll_to(&mut self, index: usize) -> String {
1168 self.history.set_rollback(index);
1169 self.rerun_history()
1170 }
1171
1172 /// Replace feature `id`'s input params and re-run at the current rollback →
1173 /// the viewport updates live.
1174 pub fn update_feature_params(
1175 &mut self,
1176 id: &str,
1177 input_params_json: &str,
1178 ) -> Result<String, String> {
1179 let mut params: serde_json::Value = serde_json::from_str(input_params_json)
1180 .map_err(|e| format!("feature params parse: {e}"))?;
1181 let index = self
1182 .history
1183 .index_of(id)
1184 .ok_or_else(|| format!("no feature with id '{id}'"))?;
1185 brep_kernel::feature_pipeline::validate_literal_feature_params(&self.feature_type_at(index).unwrap_or_default(), ¶ms)?;
1186 self.stamp_face_transform_pivot(index, &mut params);
1187 self.history.set_feature_params(index, params);
1188 Ok(self.rerun_history())
1189 }
1190
1191 /// The pattern instance `name` (a copy solid, or a face/edge of one)
1192 /// belongs to: `(pattern feature id, instance number)`. For a part Pattern
1193 /// the numbers are the kernel's copy suffixes (`PAT1_3` is instance 3; the
1194 /// source is 1); for a Pattern Component, the instance component's own
1195 /// number (`APAT1.ACOMP3.i3:Part`, or the bare component id).
1196 pub fn pattern_instance_of(&self, name: &str) -> Option<(String, i64)> {
1197 let component = name.split(':').next().unwrap_or(name);
1198 if let Some((pattern, _seed, n)) = brep_kernel::pattern_component_instance(component) {
1199 return Some((pattern, n));
1200 }
1201 let (id, ty) = self.creating_feature(name)?;
1202 if ty != "PATTERN" {
1203 return None;
1204 }
1205 let tag = format!("{id}_");
1206 let number = name
1207 .strip_prefix(&tag)
1208 .or_else(|| name.rsplit_once(&format!("::{tag}")).map(|(_, rest)| rest))?;
1209 let digits: String = number.chars().take_while(char::is_ascii_digit).collect();
1210 digits.parse().ok().map(|n| (id, n))
1211 }
1212
1213 /// The Pattern feature `id`'s suppressed instance numbers.
1214 pub fn pattern_suppressed(&self, id: &str) -> Vec<i64> {
1215 let params = self.history.index_of(id).and_then(|index| self.history.feature_params(index));
1216 brep_kernel::pattern_parse_suppressed(params.as_ref().and_then(|p| p.get("suppressed")))
1217 }
1218
1219 /// Suppress (or unsuppress) instance `number` of the Pattern feature `id`:
1220 /// one parameter edit, one undo step. `None` for `number` with
1221 /// `suppressed == false` unsuppresses every instance.
1222 pub fn pattern_set_suppressed(&mut self, id: &str, number: Option<i64>, suppressed: bool) -> Result<(), String> {
1223 let index = self.history.index_of(id).ok_or_else(|| format!("no feature with id '{id}'"))?;
1224 if number == Some(1) && suppressed {
1225 return Err(format!(
1226 "instance 1 of {id} is its source (for a Pattern Component, the seed): hide or suppress the source instead"
1227 ));
1228 }
1229 let mut list = self.pattern_suppressed(id);
1230 match (number, suppressed) {
1231 (Some(n), true) if !list.contains(&n) => list.push(n),
1232 (Some(n), false) => list.retain(|&m| m != n),
1233 (None, false) => list.clear(),
1234 _ => return Ok(()),
1235 }
1236 list.sort_unstable();
1237 let mut params = self.history.feature_params(index).unwrap_or_else(|| serde_json::json!({}));
1238 let text = list.iter().map(i64::to_string).collect::<Vec<_>>().join(", ");
1239 params["suppressed"] = serde_json::Value::String(text);
1240 self.update_feature_params(id, ¶ms.to_string()).map(|_| ())
1241 }
1242
1243 /// Replace the `inputParams` of MANY features as ONE model edit — the
1244 /// [`Self::update_feature_params`] batch sibling ([`Self::add_features`] is
1245 /// the append-only one). ONE undo checkpoint and ONE history re-run for the
1246 /// whole set.
1247 ///
1248 /// The lane that needs it: a PACKED BOM row rolls up every occurrence whose
1249 /// occurrence data matches, so editing one of its cells writes the same key
1250 /// into N ACOMP features. Looping `update_feature_params` would cost N
1251 /// re-runs and — worse — N undo entries, so taking back one visible edit
1252 /// would need N presses of undo.
1253 ///
1254 /// Unknown ids are reported (the whole batch is refused before anything is
1255 /// written, so a typo can never half-apply); an empty batch is a no-op that
1256 /// neither checkpoints nor runs.
1257 pub fn update_many_feature_params(
1258 &mut self,
1259 edits: &[(String, serde_json::Value)],
1260 ) -> Result<String, String> {
1261 if edits.is_empty() {
1262 return Ok(self.history_report.clone());
1263 }
1264 let mut resolved: Vec<(usize, serde_json::Value)> = Vec::with_capacity(edits.len());
1265 for (id, params) in edits {
1266 let index = self
1267 .history
1268 .index_of(id)
1269 .ok_or_else(|| format!("no feature with id '{id}'"))?;
1270 let mut params = params.clone();
1271 brep_kernel::feature_pipeline::validate_literal_feature_params(&self.feature_type_at(index).unwrap_or_default(), ¶ms)?;
1272 self.stamp_face_transform_pivot(index, &mut params);
1273 resolved.push((index, params));
1274 }
1275 self.history.set_many_feature_params(&resolved);
1276 Ok(self.rerun_history())
1277 }
1278
1279 /// Append a feature (a full `{type, inputParams, …}` descriptor) and roll to
1280 /// it. The caller assigns a unique `id` (see [`Self::next_feature_id`]).
1281 pub fn add_feature(&mut self, feature_json: &str) -> Result<String, String> {
1282 let feature: serde_json::Value =
1283 serde_json::from_str(feature_json).map_err(|e| format!("feature parse: {e}"))?;
1284 brep_kernel::feature_pipeline::validate_literal_feature_params(feature["type"].as_str().unwrap_or_default(), &feature["inputParams"])?;
1285 self.refuse_duplicate_ids(std::slice::from_ref(&feature))?;
1286 if !self.append_plugin_features(std::slice::from_ref(&feature))? {
1287 self.history.push_feature(feature);
1288 }
1289 let last = self.history.len().saturating_sub(1);
1290 self.history.set_rollback(last);
1291 Ok(self.rerun_history())
1292 }
1293
1294 /// Append MANY features and roll to the last — [`Self::add_feature`]'s batch
1295 /// sibling, and the reason it exists: `add_feature` re-runs the WHOLE history
1296 /// per call, so a lane that appends N features by looping it costs N rebuilds
1297 /// (O(N²) work on an N-part STEP-assembly import). This pushes all of them,
1298 /// then re-runs ONCE — one rebuild, one undo checkpoint, one
1299 /// [`applied_generation`](Self::applied_generation) bump.
1300 ///
1301 /// Deliberately NOT [`Self::set_history_json`]: that is the document-SWITCH
1302 /// path (it clears the kernel history cache and resets the runner's delta
1303 /// baseline, forcing a full cold rebuild), which is the wrong mechanism for an
1304 /// append onto the live document.
1305 ///
1306 /// An empty batch is a no-op — no checkpoint, no run, no generation bump —
1307 /// and returns the standing report. The caller assigns each feature's unique
1308 /// `id` (see [`Self::next_feature_id`]).
1309 pub fn add_features(&mut self, features: &[serde_json::Value]) -> String {
1310 if features.is_empty() {
1311 return self.history_report.clone();
1312 }
1313 for feature in features {
1314 if let Err(error) = brep_kernel::feature_pipeline::validate_literal_feature_params(feature["type"].as_str().unwrap_or_default(), &feature["inputParams"]) {
1315 return serde_json::json!({"error":error}).to_string();
1316 }
1317 }
1318 if let Err(error) = self.refuse_duplicate_ids(features) {
1319 return serde_json::json!({"error":error}).to_string();
1320 }
1321 match self.append_plugin_features(features) {
1322 Ok(true) => {},
1323 Ok(false) => self.history.push_features(features.to_vec()),
1324 Err(error) => return serde_json::json!({"error":error}).to_string(),
1325 }
1326 let last = self.history.len().saturating_sub(1);
1327 self.history.set_rollback(last);
1328 self.rerun_history()
1329 }
1330
1331 /// A feature id names its solid, its faces and every reference to it, so it
1332 /// is unique in the history: an appended feature whose `inputParams.id` is
1333 /// already used — by the history or by an earlier feature of the same
1334 /// append — is refused, naming the id. (Two features sharing an id made the
1335 /// name resolve to one solid in the kernel and to the other in the display
1336 /// and the measurements, depending on which run produced it.)
1337 fn refuse_duplicate_ids(&self, features: &[serde_json::Value]) -> Result<(), String> {
1338 let mut seen: Vec<&str> = Vec::new();
1339 for feature in features {
1340 let Some(id) = feature.get("inputParams").and_then(|p| p.get("id")).and_then(serde_json::Value::as_str) else {
1341 continue;
1342 };
1343 if id.is_empty() {
1344 continue;
1345 }
1346 if let Some(index) = self.history.index_of(id) {
1347 return Err(format!("feature id `{id}` is already used by feature {index}; choose another id (or omit it to have one assigned)"));
1348 }
1349 if seen.contains(&id) {
1350 return Err(format!("feature id `{id}` is given twice in one append"));
1351 }
1352 seen.push(id);
1353 }
1354 Ok(())
1355 }
1356
1357 /// Delete the feature with id `id` (no-op if absent) and re-run, clamping the
1358 /// rolled-to step.
1359 pub fn delete_feature(&mut self, id: &str) -> String {
1360 match self.history.index_of(id) {
1361 Some(index) => self.delete_feature_at(index),
1362 None => self.rerun_history(),
1363 }
1364 }
1365
1366 /// Delete the feature at `index` (no-op when out of range) and re-run,
1367 /// clamping the rolled-to step.
1368 ///
1369 /// The POSITIONAL twin of [`Self::delete_feature`], and the only way to
1370 /// remove a feature whose `inputParams` carry no `id` — a shape a hand-built
1371 /// history JSON can still contain, and one an id-keyed delete can never
1372 /// address.
1373 pub fn delete_feature_at(&mut self, index: usize) -> String {
1374 if index < self.history.len() {
1375 self.history.remove_feature(index);
1376 let step = self
1377 .history
1378 .rollback()
1379 .min(self.history.len().saturating_sub(1));
1380 self.history.set_rollback(step);
1381 }
1382 self.rerun_history()
1383 }
1384
1385 /// Move feature `index` one slot up/down (reorder), keeping it selected.
1386 pub fn reorder_feature(&mut self, index: usize, up: bool) -> String {
1387 let len = self.history.len();
1388 if len >= 2 {
1389 let target = if up {
1390 index.checked_sub(1)
1391 } else if index + 1 < len {
1392 Some(index + 1)
1393 } else {
1394 None
1395 };
1396 if let Some(target) = target {
1397 self.history.swap(index, target);
1398 self.history.set_rollback(target);
1399 }
1400 }
1401 self.rerun_history()
1402 }
1403
1404}
1405
1406impl EngineState {
1407 /// Whether an undo step is available (to enable the toolbar's Undo button).
1408 pub fn can_undo(&self) -> bool {
1409 self.history.can_undo()
1410 }
1411
1412 /// Whether a redo step is available.
1413 pub fn can_redo(&self) -> bool {
1414 self.history.can_redo()
1415 }
1416
1417 /// Undo the last model mutation: restore the previous document + rolled-to
1418 /// step, then re-run + reconcile the scene. Returns the build report; a no-op
1419 /// (empty undo stack) returns the last report unchanged.
1420 pub fn undo(&mut self) -> String {
1421 if self.history.undo() {
1422 self.reinstall_rewound_parts_library();
1423 self.rerun_history()
1424 } else {
1425 self.history_report.clone()
1426 }
1427 }
1428
1429 /// Open an undo group on this document's history: every edit until
1430 /// [`Self::history_group_end`] is one undo step (a script's run).
1431 pub fn history_group_begin(&mut self) -> u64 {
1432 self.history.group_begin()
1433 }
1434
1435 /// Close undo group `token`; with `rollback`, restore the document as it
1436 /// was when the group opened and re-run. Whether it rolled back.
1437 pub fn history_group_end(&mut self, token: u64, rollback: bool) -> Result<bool, String> {
1438 let rolled = self.history.group_end(token, rollback)?;
1439 if rolled {
1440 self.reinstall_rewound_parts_library();
1441 self.rerun_history();
1442 }
1443 Ok(rolled)
1444 }
1445
1446 /// [`Self::undo`] without its run, for a caller that edits the document
1447 /// straight after (the Work part replacing a coalesced step): that edit's
1448 /// run is the one the document needs, and an undo run before it would be
1449 /// executed and thrown away. Whether there was a step to undo.
1450 pub fn undo_before_edit(&mut self) -> bool {
1451 let undone = self.history.undo();
1452 if undone {
1453 self.reinstall_rewound_parts_library();
1454 }
1455 undone
1456 }
1457
1458 /// Redo the last undone model mutation (symmetric with [`Self::undo`]).
1459 pub fn redo(&mut self) -> String {
1460 if self.history.redo() {
1461 self.reinstall_rewound_parts_library();
1462 self.rerun_history()
1463 } else {
1464 self.history_report.clone()
1465 }
1466 }
1467
1468 /// Push the just-rewound `partsLibrary` block back into the kernel's
1469 /// main-side store, so time travel moves the LIBRARY with the document.
1470 ///
1471 /// Without this, undo only half-works on anything that edits a library
1472 /// entry. The undo snapshot carries the block (`History::Snapshot`), but the
1473 /// block is a MIRROR: the per-run request does not carry it
1474 /// ([`History::prefix_request`] omits it) and a run that heals or GCs the
1475 /// store writes its result back over the block (`apply_run_output`). So a
1476 /// rewound block that was never pushed into the store is simply overwritten
1477 /// again, and the undo silently does nothing — which is what
1478 /// [`Self::set_part_attribute`](crate::engine_state::EngineState::set_part_attribute)
1479 /// would hit on its first undo.
1480 ///
1481 /// `install_parts_library` matches the store to the block BY CONTENT
1482 /// IDENTITY: an identical entry is kept verbatim (so a heal this side
1483 /// derived is not clobbered), a changed one is replaced and marked dirty
1484 /// (so the ACOMP self-heal re-derives every instance), and one absent from
1485 /// the block is dropped. An unchanged block is therefore a no-op, which is
1486 /// the overwhelmingly common undo.
1487 pub(super) fn reinstall_rewound_parts_library(&mut self) {
1488 let library = serde_json::from_value(self.history.parts_library().clone())
1489 .unwrap_or_default();
1490 brep_kernel::install_parts_library(&library);
1491 }
1492
1493 // --- Selection (Esc clears / viewport click selects) ------------------
1494
1495}
1496
1497/// Stamp per-loop ids onto every SKETCH feature's geometries in a history
1498/// document, in place.
1499///
1500/// The persistence half of per-loop face naming. The kernel DERIVES a loop's id
1501/// the same way every run, so this renames nothing; what it adds is durability —
1502/// a stored id survives deleting the edge it was originally derived from, which
1503/// derivation alone cannot. Running it on document LOAD (not only on sketch
1504/// commit) means a model built by a script, an import, or any other path that
1505/// never opens the sketch editor still gets its ids written down.
1506///
1507/// Assemblies: a parts-library entry embeds a FULL sub-part history
1508/// (`partsLibrary[*].document`), whose features can include sketches of its own,
1509/// so the walk recurses into each one. Without that, a sketch inside an imported
1510/// assembly part would derive correct names but carry no stored ids — exactly the
1511/// case (deleting the edge an id came from) that persisting exists to cover.
1512/// Depth is bounded: a sub-document's own library is seeded from the kernel store
1513/// rather than nested inside the entry, so one level of recursion reaches all of
1514/// them, and `MAX_LIBRARY_DEPTH` stops a malformed self-referential document.
1515///
1516/// Malformed features are skipped rather than rejected: this is a best-effort
1517/// enrichment on the way to the kernel, which validates the document itself.
1518fn stamp_sketch_loop_ids(document: &mut serde_json::Value) {
1519 /// Depth cap for the embedded sub-document walk — a guard against a
1520 /// hand-edited or corrupt document that nests libraries into each other.
1521 const MAX_LIBRARY_DEPTH: usize = 8;
1522 stamp_sketch_loop_ids_to_depth(document, MAX_LIBRARY_DEPTH);
1523}
1524
1525/// [`stamp_sketch_loop_ids`] with the remaining recursion budget.
1526fn stamp_sketch_loop_ids_to_depth(document: &mut serde_json::Value, depth: usize) {
1527 if let Some(features) = document
1528 .get_mut("features")
1529 .and_then(serde_json::Value::as_array_mut)
1530 {
1531 for feature in features {
1532 if feature.get("type").and_then(serde_json::Value::as_str) != Some("S") {
1533 continue;
1534 }
1535 let Some(sketch) = feature
1536 .get_mut("persistentData")
1537 .and_then(|data| data.get_mut("sketch"))
1538 else {
1539 continue;
1540 };
1541 brep_kernel::assign_sketch_loop_ids(sketch);
1542 }
1543 }
1544 if depth == 0 {
1545 return;
1546 }
1547 // Each library entry's embedded sub-part history gets the same treatment.
1548 let Some(library) = document
1549 .get_mut("partsLibrary")
1550 .and_then(serde_json::Value::as_object_mut)
1551 else {
1552 return;
1553 };
1554 for (_, entry) in library.iter_mut() {
1555 let Some(embedded) = entry.get_mut("document") else {
1556 continue;
1557 };
1558 stamp_sketch_loop_ids_to_depth(embedded, depth - 1);
1559 }
1560}
1561
1562impl EngineState {
1563 /// Load a whole model document (a saved `.nbrep` recipe) and FRAME it:
1564 /// [`set_history_json`](Self::set_history_json) (which rolls to the last
1565 /// feature) followed by [`zoom_to_fit`](Self::zoom_to_fit). The one call the
1566 /// file panel's **Open** needs — the model IS the engine-owned history, so
1567 /// opening a file is loading its request JSON and reframing. Returns the
1568 /// build-report JSON.
1569 pub fn load_model_and_fit(&mut self, request_json: &str) -> Result<String, String> {
1570 // Frame once the run lands, not now: under a background runner (native
1571 // thread / wasm worker) the freshly loaded model is not resident yet when
1572 // `set_history_json` returns, so an immediate `zoom_to_fit` would frame the
1573 // OLD scene. Set BEFORE the submit so the Inline runner's in-call `pump`
1574 // still frames synchronously. See [`EngineState::pending_fit`].
1575 self.pending_fit = true;
1576 let result = self.set_history_json(request_json);
1577 if result.is_err() {
1578 // A rejected document submits no run, so the armed fit would otherwise
1579 // fire on the OLD scene next frame — disarm it.
1580 self.pending_fit = false;
1581 }
1582 result
1583 }
1584}
1585
1586
1587
1588
1589
1590
1591/// The run report's `featureRefusals` object, added only when a feature failed
1592/// with a TYPED refusal: feature id → the kernel's refusal as it serializes
1593/// (`class` and its payload, `stage`, `message` — the text `featureErrors`
1594/// carries for the same feature) plus `step`, the motion step that refused,
1595/// when the feature records one (Transform Face: `rotation` / `translation`).
1596/// Absent otherwise, so a report with no typed refusal is byte-for-byte what it
1597/// was before this key existed.
1598fn with_refusals(
1599 mut value: serde_json::Value,
1600 report: &crate::pipeline::SceneBuildReport,
1601) -> serde_json::Value {
1602 if report.feature_refusals.is_empty() {
1603 return value;
1604 }
1605 let refusals: serde_json::Map<String, serde_json::Value> = report
1606 .feature_refusals
1607 .iter()
1608 .map(|(id, refusal, step)| {
1609 let mut value = serde_json::to_value(refusal).unwrap_or(serde_json::Value::Null);
1610 if let (Some(step), serde_json::Value::Object(object)) = (step, &mut value) {
1611 object.insert("step".into(), serde_json::Value::String(step.clone()));
1612 }
1613 (id.clone(), value)
1614 })
1615 .collect();
1616 if let serde_json::Value::Object(object) = &mut value {
1617 object.insert("featureRefusals".into(), serde_json::Value::Object(refusals));
1618 }
1619 value
1620}
1621
1622/// The run report's `featureApproximations` object, added only when a feature
1623/// SUCCEEDED carrying a MEASURED approximation: feature id → the kernel's
1624/// approximations as they serialize (`code`, `body`, `measured`, `bar`,
1625/// `volume_bound`, `edges[{edge_id, curve_ref, face_ref, surface_ref,
1626/// off_carrier_mm, origin}]`, `message`), each plus a one-line `summary` the
1627/// history tree shows as the feature's leaf. Absent otherwise, so a report with
1628/// no approximation is byte-for-byte what it was before this key existed — the
1629/// same contract as `featureRefusals`. Distinct from `featureRefusals` (the
1630/// result does not stand) and `featureFulfilment` (what was done of what was
1631/// asked): an approximation's result stands, and this says by how much it is
1632/// not exact.
1633fn with_approximations(
1634 mut value: serde_json::Value,
1635 report: &crate::pipeline::SceneBuildReport,
1636) -> serde_json::Value {
1637 if report.feature_approximations.is_empty() {
1638 return value;
1639 }
1640 let approximations: serde_json::Map<String, serde_json::Value> = report
1641 .feature_approximations
1642 .iter()
1643 .map(|(id, approximations)| {
1644 let entries: Vec<serde_json::Value> = approximations
1645 .iter()
1646 .map(|approximation| {
1647 let mut value =
1648 serde_json::to_value(approximation).unwrap_or(serde_json::Value::Null);
1649 if let serde_json::Value::Object(object) = &mut value {
1650 object.insert(
1651 "summary".into(),
1652 serde_json::Value::String(approximation_summary(approximation)),
1653 );
1654 }
1655 value
1656 })
1657 .collect();
1658 (id.clone(), serde_json::Value::Array(entries))
1659 })
1660 .collect();
1661 if let serde_json::Value::Object(object) = &mut value {
1662 object.insert("featureApproximations".into(), serde_json::Value::Object(approximations));
1663 }
1664 value
1665}
1666
1667/// One line for an approximation, for the history tree's leaf: the marker
1668/// word, the code, the body, the measurement against its bar and the volume
1669/// bound when the code implies one. Code-agnostic on purpose — the units of
1670/// `measured` are the code's (mm² for `import.shell_closure`), so only the
1671/// volume bound, which every code states in mm³, carries a unit here; the
1672/// kernel's `message` has the full text with units, faces and edges.
1673pub fn approximation_summary(approximation: &brep_kernel::Approximation) -> String {
1674 let ratio = if approximation.bar > 0.0 {
1675 format!(" ({:.1}x)", approximation.measured / approximation.bar)
1676 } else {
1677 String::new()
1678 };
1679 let bound = approximation
1680 .volume_bound
1681 .map(|bound| format!("; volume determined to ±{bound:.3e} mm³"))
1682 .unwrap_or_default();
1683 format!(
1684 "approximate ({}): {} measured {:.3e} against a bar of {:.3e}{ratio}{bound}",
1685 approximation.code, approximation.body, approximation.measured, approximation.bar
1686 )
1687}
1688
1689/// The run report's `featureFulfilment` object: feature id → the typed
1690/// fulfilment (`requested`, `applied`, `rejected[{name, kind, reason}]`) plus a
1691/// one-line `summary` the tree and the automation report show verbatim. Only the
1692/// features that reported one appear, so an absent key means "did everything".
1693fn fulfilment_map(report: &crate::pipeline::SceneBuildReport) -> serde_json::Map<String, serde_json::Value> {
1694 report
1695 .feature_fulfilment
1696 .iter()
1697 .map(|(id, fulfilment)| {
1698 let mut value = serde_json::to_value(fulfilment).unwrap_or(serde_json::Value::Null);
1699 if let serde_json::Value::Object(object) = &mut value {
1700 object.insert("summary".into(), serde_json::Value::String(fulfilment.summary()));
1701 }
1702 (id.clone(), value)
1703 })
1704 .collect()
1705}
1706
1707impl EngineState {
1708 /// Adopt one solver pose write-back without an undo entry: a component's
1709 /// pose into its ACOMP `transform` (unless expressions drive it); a
1710 /// flexible sub-assembly occurrence's (`ACOMP1:ACOMP2`, part-local) into
1711 /// its instance's `flexiblePoses`, never the child document.
1712 pub(crate) fn fold_assembly_pose(&mut self, id: &str, pose: serde_json::Value) {
1713 if let Some((owner, path)) = id.split_once(':') {
1714 let Some(index) = self.history.index_of(owner) else { return };
1715 let mut poses = self
1716 .history
1717 .feature_params(index)
1718 .and_then(|params| params.get("flexiblePoses").cloned())
1719 .filter(serde_json::Value::is_object)
1720 .unwrap_or_else(|| serde_json::json!({}));
1721 poses[path] = pose;
1722 self.history.fold_param_no_undo(owner, "flexiblePoses", poses);
1723 return;
1724 }
1725 let expression_driven = self.history.index_of(id).and_then(|i| self.history.feature_params(i))
1726 .is_some_and(|p| super::batch::contains_expression(&p["transform"]));
1727 if !expression_driven { self.history.fold_param_no_undo(id, "transform", pose); }
1728 }
1729}
1730