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(with_refusals(
19 serde_json::json!({
20 "featureErrors": report.feature_errors,
21 "featureNotes": report.feature_notes,
22 "featureFulfilment": fulfilment_map(&report),
23 "unresolved": report.unresolved,
24 "displayErrors": report.display_errors,
25 }),
26 &report,
27 )
28 .to_string())
29 }
30
31 /// (Re)run the current rolled-to prefix of the engine's history through the
32 /// SAME kernel pipeline and reconcile the display scene. Stores + returns the
33 /// build report JSON.
34 pub(super) fn rerun_history(&mut self) -> String {
35 // This run publishes whatever a symbol edit did to the PORT features,
36 // so `pump` need not run again for it.
37 self.history.take_ports_followed();
38 // Keep the incremental cache when rolling or editing: producer entries
39 // own their handles, so rolling before a consuming boolean can reuse its
40 // inputs. Document switches clear the cache in `set_history_json`.
41 // Tag each submission with a generation; `pump` applies completed deltas
42 // immediately for the inline runner or on a later frame for workers.
43 // A transient preview replaces what the run evaluates, in the request
44 // only: the history keeps its own source.
45 self.drop_stale_expression_preview();
46 let request_value = self.run_request_value();
47 match serde_json::from_value::<HistoryRequest>(request_value) {
48 Ok(mut request) => {
49 // Carry the live display LOD to the runner (the request is the run
50 // boundary the thread/worker receives). The runner re-tessellates
51 // every resident mesh when this differs from its last run's lod.
52 request.display_lod = self.settings.lod_factor;
53 self.run_generation += 1;
54 // A new run supersedes a cancelled one's notice.
55 self.cancelled_run = None;
56 // The PARTS-LIBRARY CHANNEL. A background runner (native
57 // thread / browser worker) owns its own kernel store, so it
58 // needs the library — but sending it WITH every run meant
59 // stringifying every embedded part payload on the UI thread
60 // for every edit, which froze the browser on an imported STEP
61 // assembly. It is sent only when it CHANGES; `fetch` does not
62 // run in the steady state. Inline shares this thread's store
63 // and no-ops.
64 self.runner.sync_parts_library(
65 brep_kernel::parts_library_revision(),
66 &mut brep_kernel::parts_library_map,
67 );
68 self.runner.submit_run(request, self.run_generation);
69 }
70 // A parse failure runs nothing: clear the surfaced frames/profiles (so
71 // stale construction geometry does not linger — the scene keeps its
72 // previous solids) and set an error report, then run the shared
73 // post-apply tail synchronously (no kernel work), so this branch shares
74 // the dirty/gizmo/overlay continuation verbatim with a real apply.
75 Err(error) => {
76 self.construction_frames.clear();
77 self.sketch_profiles.clear();
78 self.sketch_paths.clear();
79 self.sketch_points.clear();
80 self.sketch_axes.clear();
81 self.wire_harness_report = None;
82 self.ports_report = None;
83 self.finish_apply(
84 serde_json::json!({ "error": format!("history request: {error}") }).to_string(),
85 );
86 }
87 }
88 // Inline applies the submitted run NOW; a thread impl would defer it to a
89 // later frame's `pump`. Either way `history_report` is fresh once the reply
90 // is applied — for Inline that is before this call returns.
91 self.pump();
92 self.history_report.clone()
93 }
94
95 /// Drain every completed run reply and APPLY it — the POLL/APPLY half of the
96 /// M2a seam. Called from [`rerun_history`](Self::rerun_history) for the Inline
97 /// runner's immediate apply, and once per frame from the app so a future async
98 /// runner's completed runs land on the main thread. A reply older than
99 /// [`applied_generation`](Self::applied_generation) (a newer run that finished
100 /// first) is dropped.
101 pub fn pump(&mut self) {
102 // The runner REFUSED a run because its resident parts library could not
103 // serve it (see `Reply::NeedPartsLibrary`). It has already forgotten
104 // its copy, so re-running re-installs the library and re-submits. This
105 // is a real path, not just a tripwire: the kernel's orphan GC drops
106 // entries at the end of every run, so an undo to zero components empties
107 // the RUNNER's store while this side's (which never ran) keeps
108 // everything — no revision bookkeeping can see that, only the runner's
109 // content preflight can. `library_resync` breaks the rerun→pump→rerun
110 // recursion (the reinstall makes the second attempt succeed, but a
111 // guard beats relying on that). The guard is tested FIRST because
112 // `poll_library_request` CONSUMES the flag — polling it while a resync
113 // is already in flight would swallow a second refusal.
114 if !self.library_resync && self.runner.poll_library_request() {
115 self.library_resync = true;
116 self.rerun_history();
117 self.library_resync = false;
118 }
119 // A symbol edit relabelled, added or removed a PORT feature. The eCAD
120 // editors write their blocks straight into the history, which does not
121 // run it, so the run happens here, on the next frame, for every host.
122 if self.history.take_ports_followed() {
123 self.rerun_history();
124 }
125 while let Some(reply) = self.runner.poll_mesh_import() {
126 // A document switch clears this set. Ignore any older reconstruction
127 // reply that was already running when its Reset crossed the queue.
128 let Some(destination) = self.pending_mesh_imports.remove(&reply.id) else {
129 continue;
130 };
131 if destination == MeshImportDestination::Preview {
132 self.mesh_preview_results.push_back(reply);
133 continue;
134 }
135 match reply.result {
136 Ok(output) => match self.import_step_feature(&output.step_text) {
137 Ok(_) => {
138 self.push_notice(
139 "RANSAC reconstruction complete; building imported CAD body",
140 );
141 // A body the chain refused is left out of the model;
142 // say so, by name, rather than let it vanish.
143 if let Some(notice) = crate::runner::refused_bodies_notice(&output.report)
144 {
145 self.push_notice(notice);
146 }
147 }
148 Err(error) => self.push_notice(format!("mesh import failed: {error}")),
149 },
150 Err(error) => self.push_notice(format!("mesh import failed: {error}")),
151 }
152 }
153 // STEP probes: the parse ran on the runner; stash the structure it
154 // found (the import consumes it) and queue the outcome for the panel.
155 while let Some(reply) = self.runner.poll_step_probe() {
156 if !self.pending_step_probes.remove(&reply.id) {
157 continue; // cancelled, or a document switch — nobody is waiting
158 }
159 let outcome = match reply.result {
160 Ok(Some(assembly)) => {
161 let probe = super::model_io::probe_counts(&assembly);
162 // The kernel already refuses a structure that reaches no
163 // geometry, so this is belt-and-braces: an assembly with
164 // zero instances would import as zero components, which
165 // is the silent failure the structured lane forbids.
166 if probe.instances == 0 {
167 super::StepProbeOutcome::Flat
168 } else {
169 self.pending_step_assembly = Some(assembly);
170 super::StepProbeOutcome::Structure(probe)
171 }
172 }
173 Ok(None) => super::StepProbeOutcome::Flat,
174 Err(error) => super::StepProbeOutcome::Failed(error),
175 };
176 self.step_probe_results.push_back((reply.id, outcome));
177 }
178 // The in-flight run's latest progress report (a background runner
179 // posts one before each feature it executes). Kept only for a run
180 // newer than the applied one: a report from a superseded run — or one
181 // still arriving after a cancel bumped the generations — is stale.
182 if let Some(progress) = self.runner.poll_progress() {
183 if progress.generation > self.applied_generation {
184 self.run_progress = Some(progress);
185 }
186 }
187 while let Some(reply) = self.runner.poll_run() {
188 self.runs_replied += 1;
189 if reply.generation >= self.applied_generation {
190 self.applied_generation = reply.generation;
191 self.apply_run_output(reply.output);
192 }
193 }
194 if !self.run_pending() {
195 self.run_progress = None;
196 }
197 // Deferred one-shot framing (Import / Open): frame the scene the moment the
198 // run they submitted has fully landed. Consumed unconditionally once the run
199 // is no longer pending — even when it produced no solids (bbox empty →
200 // `zoom_to_fit` no-ops) — so a later unrelated run never inherits a stale fit.
201 if self.pending_fit && !self.run_pending() {
202 self.pending_fit = false;
203 self.zoom_to_fit();
204 }
205 // Drain any completed measurement replies too (a background runner surfaces
206 // them a frame after selection); for Inline this is a no-op each frame since
207 // `object_info_json` already pumped its own query same-call.
208 self.pump_queries();
209 self.pump_topology();
210 self.pump_sheet_lines();
211 }
212
213 /// Ask the runner for the B-rep topology of the displayed solids `names`
214 /// that the scene does not hold for their current handle and that have not
215 /// been asked for since the last applied run, then drain whatever has
216 /// already answered (all of it, for the synchronous Inline runner). The
217 /// drawing sheet's door: nothing else needs topology on this side, so a
218 /// document with no sheet never asks.
219 pub fn request_exact_solids<'a>(&mut self, names: impl IntoIterator<Item = &'a str>) {
220 let mut wanted: Vec<(String, u32)> = Vec::new();
221 for name in names {
222 let Some(display) = self.scene.solid(name) else { continue };
223 let handle = display.source_handle;
224 if handle == 0 || display.is_sketch || self.scene.exact_solid(name).is_some() {
225 continue;
226 }
227 if self.topology_asked.insert(handle) {
228 wanted.push((name.to_string(), handle));
229 }
230 }
231 if !wanted.is_empty() {
232 self.next_topology_id += 1;
233 let id = self.next_topology_id;
234 self.pending_topology.insert(id);
235 self.runner.submit_topology(crate::runner::TopologyRequest { id, solids: wanted });
236 }
237 self.pump_topology();
238 }
239
240 /// Fold every answered topology request into the scene. A reply for a
241 /// display that has moved on to another handle is dropped; the next
242 /// projection asks for the new one.
243 pub fn pump_topology(&mut self) {
244 while let Some(reply) = self.runner.poll_topology() {
245 if !self.pending_topology.remove(&reply.id) {
246 continue; // cancelled, or a document switch
247 }
248 for (name, handle, solid) in reply.solids {
249 self.scene.set_exact_solid(&name, handle, solid);
250 }
251 }
252 }
253
254 /// Whether a topology request is still in flight — the frame loop and the
255 /// idle contract wait on it like a measurement query.
256 pub fn topology_pending(&self) -> bool {
257 !self.pending_topology.is_empty()
258 }
259
260 /// Whether a measurement query is still in flight (its reply not yet drained) —
261 /// the query analogue of [`run_pending`](Self::run_pending), so the app keeps the
262 /// frame loop alive until a background runner's measurement lands and displays.
263 /// Always `false` for the synchronous Inline runner.
264 pub fn queries_pending(&self) -> bool {
265 !self.pending_query.is_empty()
266 }
267
268 /// Whether RANSAC reconstruction is still executing on the native runner
269 /// thread or browser worker.
270 pub fn mesh_imports_pending(&self) -> bool {
271 !self.pending_mesh_imports.is_empty()
272 }
273
274 /// Whether a submitted run has not yet been applied (`run_generation !=
275 /// applied_generation`). Always `false` for the synchronous Inline runner
276 /// (submit → immediate `pump` keeps the two in lockstep); a background runner
277 /// uses it to keep the frame loop alive until its reply lands.
278 pub fn run_pending(&self) -> bool {
279 self.run_generation != self.applied_generation
280 }
281
282 /// The generation of the last APPLIED run — bumps once per applied history
283 /// run (document loads, edits, constraint mutations, solves). A cheap
284 /// staleness key for app-side caches derived from the applied document
285 /// (the update-components outdated badge keys on it).
286 pub fn applied_generation(&self) -> u64 {
287 self.applied_generation
288 }
289
290 /// What the in-flight run is executing right now, as far as the runner
291 /// has reported (see [`crate::runner::RunProgress`]); `None` when nothing
292 /// is running or the run has not reached its first executed feature.
293 pub fn run_progress(&self) -> Option<&crate::runner::RunProgress> {
294 self.run_progress.as_ref()
295 }
296
297 /// The feature id the last cancelled run was executing (empty when it was
298 /// cancelled before any progress arrived), until the next submit.
299 pub fn cancelled_run(&self) -> Option<&str> {
300 self.cancelled_run.as_deref()
301 }
302
303 /// CANCEL the in-flight run. The runner abandons its work and comes back
304 /// with an EMPTY resident registry (a fresh thread / a fresh worker — see
305 /// [`crate::runner::HistoryRunner::cancel`]), so this side forgets
306 /// everything that was waiting on it: the run itself (generations are
307 /// bumped past it, so a straggling reply from the old runner is dropped
308 /// as stale), pending measurement queries, document-bound mesh imports
309 /// and a deferred fit. The DISPLAY SCENE is left as the last applied run
310 /// built it — the document is ahead of it now, which the notice says; the
311 /// next edit re-runs the whole history through the new runner (a cold
312 /// run: the warm cache went with the old one). Nothing inside a feature
313 /// is interruptible, so the native thread keeps burning CPU until the
314 /// feature it is on finishes; the browser worker is terminated outright.
315 ///
316 /// `false` when nothing was running, or the runner cannot abandon (the
317 /// synchronous Inline runner, whose runs are over before anyone can ask).
318 pub fn cancel_run(&mut self) -> bool {
319 if !self.run_pending() || !self.runner.cancel() {
320 return false;
321 }
322 let stalled_on = self.run_progress.take().map(|progress| progress.feature_id);
323 // Past every generation submitted so far: a reply the old runner
324 // already posted (sitting in the main event loop on wasm) carries an
325 // older number than this and is dropped by `pump`'s gate.
326 self.run_generation += 1;
327 self.applied_generation = self.run_generation;
328 self.pending_query.clear();
329 // The new runner's registry counts handles from one again, so every
330 // topology keyed by an old handle could alias a new display.
331 self.pending_topology.clear();
332 self.topology_asked.clear();
333 self.scene.clear_exact_solids();
334 self.sheet_lines.forget();
335 let dropped_imports = self.pending_mesh_imports.len();
336 self.pending_mesh_imports.clear();
337 let dropped_probes = self.pending_step_probes.len();
338 self.pending_step_probes.clear();
339 self.pending_fit = false;
340 self.library_resync = false;
341 self.cancelled_run = Some(stalled_on.clone().unwrap_or_default());
342 self.push_notice(match stalled_on {
343 Some(id) if !id.is_empty() => format!(
344 "Run cancelled while executing {id}. The model shows the last completed \
345 result; edit or delete the feature to rebuild."
346 ),
347 _ => "Run cancelled. The model shows the last completed result; the next edit \
348 rebuilds it."
349 .to_string(),
350 });
351 if dropped_imports > 0 {
352 self.push_notice(format!(
353 "cancelled {dropped_imports} pending mesh import{}",
354 if dropped_imports == 1 { "" } else { "s" }
355 ));
356 }
357 if dropped_probes > 0 {
358 self.push_notice("cancelled the STEP file being read — upload it again to import it");
359 }
360 true
361 }
362
363 /// How many run replies the runner has sent back (see `runs_replied`).
364 pub fn runs_replied(&self) -> u64 {
365 self.runs_replied
366 }
367
368 /// Whether the display scene currently holds at least one solid. Used by the
369 /// app's async-safe first-frame framing: under a background runner (thread /
370 /// worker) the seed run lands a frame (or many) after boot, so the shell waits
371 /// for `has_solids() && !run_pending()` before its one-shot `zoom_to_fit`.
372 pub fn has_solids(&self) -> bool {
373 !self.scene.solids().is_empty()
374 }
375
376 /// Swap in a different history runner (the platform injects its own — the native
377 /// app installs a [`ThreadRunner`](crate::runner::ThreadRunner); wasm keeps the
378 /// default Inline until M3's worker). Resets the new runner's delta baseline so
379 /// the next run rebuilds fully. Call BEFORE seeding a document so the seed builds
380 /// through the installed runner.
381 pub fn set_runner(&mut self, runner: Box<dyn crate::runner::HistoryRunner>) {
382 self.runner = runner;
383 self.runner.reset();
384 self.pending_mesh_imports.clear();
385 self.mesh_preview_results.clear();
386 self.pending_topology.clear();
387 self.topology_asked.clear();
388 self.scene.clear_exact_solids();
389 self.sheet_lines.forget();
390 }
391
392 /// Apply a [`SceneRunner`](crate::pipeline::SceneRunner) delta to the display
393 /// scene and build the history report JSON — the APPLY half of the M2a seam.
394 ///
395 /// Reconcile preserving ORDER + reuse: MOVE every current display out of the
396 /// scene ([`RenderScene::drain`](crate::scene::RenderScene::drain)) into a
397 /// name-keyed `kept` map, then reinsert in snapshot order — a fresh entry
398 /// (`Some`) replaces, an UNCHANGED entry (`None`) reuses its moved-out display
399 /// (stable `revision` ⇒ GPU-buffer reuse, Task-1; its `source_handle` equals
400 /// the run's handle by the monotonic-handle reuse invariant). Leftovers in
401 /// `kept` — departed kernel solids AND the previous run's sketch sheets — are
402 /// dropped; `refresh_committed_sketches` (run in the shared continuation after)
403 /// re-adds the sheets, so dropping them here is correct.
404 ///
405 /// Then the report continuation (identical to the pre-seam run): keep the run's
406 /// resolved frames + solved sketch profiles and fold the per-feature timings /
407 /// output-names into the id-keyed report JSON, then hand it to
408 /// [`finish_apply`](Self::finish_apply) — the shared dirty/gizmo/overlay tail
409 /// that the parse-error branch in [`rerun_history`](Self::rerun_history) also
410 /// calls, so both paths share the continuation verbatim.
411 fn apply_run_output(&mut self, output: crate::pipeline::RunOutput) {
412 let crate::pipeline::RunOutput {
413 snapshot,
414 report,
415 provenance,
416 entity_origin,
417 assembly_poses,
418 assembly_fixed,
419 moved_solids,
420 imported_colors,
421 consumed,
422 assembly,
423 } = output;
424 // The runner already forced fresh displays for the solver-moved solids
425 // (their snapshot entries arrive `Some`); nothing extra to do main-side.
426 let _ = moved_solids;
427 // Un-pose the active PMI view's exploded displays BEFORE the reconcile
428 // keeps them, so the re-pose after apply starts from the modeling
429 // pose and never compounds.
430 self.pmi_restore_explode();
431
432 // The names this run consumed, for the committed-sketch refresh in the
433 // tail below — the runner folded them, so the UI thread does not execute
434 // the history a second time to fold them again.
435 self.consumed_names = consumed.into_iter().collect();
436 // Adopt the run's assembly tail. The PARTS LIBRARY comes out of it here
437 // rather than in `adopt_assembly_sync`, so the (potentially megabyte)
438 // store is installed and mirrored into the document exactly once, on the
439 // run that moved it, and never kept in the snapshot afterwards.
440 self.assembly_sync = assembly.map(|mut sync| {
441 if let Some(library) = sync.parts_library.take() {
442 // CLEAR, then install. `install_parts_library` keeps a resident
443 // entry whose identity (source key + signature + document hash)
444 // matches the incoming one VERBATIM — the right rule for the
445 // main → runner direction it was written for, because the
446 // runner may hold a heal the sender never saw. This is the
447 // other direction: the run that just healed an entry (a heal
448 // rewrites the snapshot and deliberately does NOT change the
449 // identity) is the one holding the newer copy, so keeping the
450 // resident one would silently drop it and the document would
451 // re-heal on every open. Emptying first makes every incoming
452 // entry a fresh insert, i.e. "the store IS what the run left".
453 brep_kernel::install_parts_library(&Default::default());
454 brep_kernel::install_parts_library(&library);
455 if let Ok(block) = serde_json::to_value(&library) {
456 self.history.set_parts_library(block);
457 }
458 }
459 sync
460 });
461
462 // --- assembly pose-authority fold -------- Adopt the solver's pose /
463 // isFixed write-backs into the owning ACOMP features by
464 // `inputParams.id` BEFORE anything persists or re-runs this document.
465 // Deliberately NOT `update_feature_params`: the fold must not mint an
466 // undo entry nor trigger a rerun (rerun → solve → fold → rerun would
467 // loop); `fold_param_no_undo` writes the param silently, and the next
468 // run's request simply carries the solved pose (a no-motion solve emits
469 // no updates, so fingerprints never churn).
470 for (id, pose) in assembly_poses {
471 self.history.fold_param_no_undo(&id, "transform", pose);
472 }
473 for (id, fixed) in assembly_fixed {
474 self.history
475 .fold_param_no_undo(&id, "isFixed", serde_json::Value::Bool(fixed));
476 }
477
478 // Adopt the run's eager provenance (SOLID last-writer) + entity origin
479 // (face/edge first-writer) wholesale (both drive `creating_feature` + the
480 // Info tab's `creatingFeature` with no cold re-run), and INVALIDATE the
481 // object-info measurement cache + any in-flight query: the geometry changed,
482 // so cached measurements are stale and a pending reply is superseded (a
483 // re-selection re-queries against the fresh geometry).
484 self.provenance = provenance.into_iter().collect();
485 self.entity_origin = entity_origin.into_iter().collect();
486 self.info_cache.clear();
487 self.pending_query.clear();
488
489 // Fold the run's IMPORTED COLOURS into the engine's own metadata store —
490 // the seam between the kernel's (thread-local, never persisted by us)
491 // name-keyed store and the one the Info window edits and the document
492 // saves. NON-overwriting on purpose: an import re-stamps its colour on
493 // every replay, and a colour the user changed in the panel must win.
494 for (name, hex) in imported_colors {
495 if self.metadata.attribute(&name, "color").is_none() {
496 self.metadata.set_attribute(&name, "color", &hex);
497 }
498 }
499
500 // Reconcile the scene: move current displays out, reinsert in order.
501 let mut kept: std::collections::HashMap<String, crate::scene::SolidDisplay> = self
502 .scene
503 .drain()
504 .into_iter()
505 .map(|solid| (solid.name.clone(), solid))
506 .collect();
507 for (name, _handle, maybe) in snapshot {
508 match maybe {
509 Some(display) => self.scene.insert_solid(display),
510 None => self
511 .scene
512 .insert_solid(kept.remove(&name).expect("keep target present")),
513 }
514 }
515 // Topology is kept only for a display whose handle survived the run,
516 // and so is the record of what was asked: a surviving handle is in
517 // flight, answered or not in the registry, and every other is gone.
518 self.scene.prune_exact_solids();
519 let live: std::collections::HashSet<u32> =
520 self.scene.solids().iter().map(|solid| solid.source_handle).collect();
521 self.topology_asked.retain(|handle| live.contains(handle));
522
523 // Keep every plane frame the run resolved (DATUM/PLANE/SKETCH);
524 // `refresh_construction_datums` filters to the D/P producers.
525 self.construction_frames = report.frames.clone();
526 // Keep every solved sketch profile so `refresh_committed_sketches` can
527 // synthesize the committed sketch sheet solids.
528 self.sketch_profiles = report.profiles.clone();
529 // ...and every path chain, so a sketch whose geometry closes NO region (an
530 // open chain — the reported single line) still has something to draw.
531 self.sketch_paths = report.paths.clone();
532 // ...and every published point, so a sketch holding ONLY points (a
533 // hole-placement sketch) still has something to draw.
534 self.sketch_points = report.points.clone();
535 // Keep every axis line the run published so the angle gizmo can resolve a
536 // revolve `axis` reference to a world line without re-running.
537 self.sketch_axes = report.axes.clone();
538 // ...and the wire-harness routing report, for the harness panel.
539 self.wire_harness_report = report.wire_harness.clone();
540 // ...and the ports tail's report, for the Qualify panel: the tail is not
541 // a feature, so nothing else carries what it refused or could not resolve.
542 self.ports_report = report.ports.clone();
543 // ...and the PMI report (every view's resolved annotations).
544 self.pmi_report = report.pmi.clone();
545 // Fold the per-feature timing / output-name pairs into id-keyed maps so the
546 // history-tree UI can look them up by feature id.
547 let timings: serde_json::Map<String, serde_json::Value> = report
548 .feature_timings
549 .iter()
550 .map(|(id, ms)| (id.clone(), serde_json::json!(ms)))
551 .collect();
552 let outputs: serde_json::Map<String, serde_json::Value> = report
553 .feature_outputs
554 .iter()
555 .map(|(id, names)| (id.clone(), serde_json::json!(names)))
556 .collect();
557 let report_json = with_refusals(
558 serde_json::json!({
559 "featureErrors": report.feature_errors,
560 "featureNotes": report.feature_notes,
561 "featureFulfilment": fulfilment_map(&report),
562 "unresolved": report.unresolved,
563 "displayErrors": report.display_errors,
564 "featureTimings": timings,
565 "featureOutputs": outputs,
566 }),
567 &report,
568 )
569 .to_string();
570 self.finish_apply(report_json);
571 }
572
573 /// The shared post-apply TAIL: mark dirty, store the report JSON, re-sync an
574 /// armed gizmo, and rebuild the persistent committed-sketch + construction-datum
575 /// overlays. Called after a real run's [`apply_run_output`](Self::apply_run_output)
576 /// AND from [`rerun_history`](Self::rerun_history)'s parse-error branch, so both
577 /// paths run the identical continuation. Callers read the result via
578 /// [`Self::history_report`](Self::history_report_json).
579 fn finish_apply(&mut self, report_json: String) {
580 self.dirty = true;
581 self.history_report = report_json;
582 // Keep an armed gizmo glued to its feature as the model rebuilds. During a
583 // transform drag the re-sync is driven by `transform_drag_to` itself (which
584 // resolves the delta against the frozen grab frame first, then syncs), so
585 // skip it here to avoid a redundant double-feed per drag frame. Transform
586 // mode re-feeds the widget frame; dimension mode re-projects the annotation
587 // leaders onto the rebuilt (param-changed) geometry.
588 if self.transform_gizmo.drag.is_none() {
589 match self.transform_gizmo.mode {
590 GizmoMode::Transform => self.sync_transform_gizmo(),
591 GizmoMode::Dimension => self.refresh_feature_dimension_overlay(),
592 GizmoMode::None => {}
593 }
594 }
595 // Rebuild the persistent committed-sketch overlays against the reconciled
596 // scene (also covers `set_history_json`, which returns this call's result).
597 self.refresh_committed_sketches();
598 // Rebuild the persistent construction datum/plane overlays from the frames
599 // the run just surfaced (D/P features only; sketches render as curves).
600 self.refresh_construction_datums();
601 // Rebuild the BOARD's 3D bodies from the `pcb` block, for the same reason
602 // the sketch sheets are rebuilt here: the reconcile above kept only the
603 // run's own outputs. A document with no `pcb` block returns immediately
604 // inside.
605 self.refresh_board_geometry();
606 // Assembly documents: adopt the component projection and the solved
607 // constraint state the runner shipped, and fold that state back into the
608 // document (the pose-authority contract — see `assembly_ops`).
609 // Componentless documents return immediately inside.
610 self.adopt_assembly_sync();
611 // Component selection needs the new member projection above, not the
612 // previous run's assembly. This also completes a deferred Move Copy.
613 // Keep an armed COMPONENT Move gizmo glued to its (possibly re-solved)
614 // component: re-anchor at the fresh member bbox. Never during its own
615 // drag — the drag feed owns the widget frame (free-move live-follow).
616 if self.component_move.drag.is_none() {
617 self.component_move_sync();
618 }
619 // Rebuild the assembly-constraint viewport overlays from the rows the
620 // same reply carried (an inert no-op — empty group — for a document with
621 // no assembly state). ORDER MATTERS: the overlay read must follow the
622 // adopt.
623 self.refresh_constraint_overlay();
624 // PMI: re-pose the active view's exploded solids on the fresh displays
625 // and re-bake its annotation overlay from the run's report.
626 self.pmi_after_apply();
627 // Re-derive every display colour from the metadata store, LAST — after
628 // the sketch sheets, the datum overlays and the assembly sync have all
629 // settled the scene, so no display inserted above is missed. This is why
630 // a model colour now survives a feature edit: the freshly tessellated
631 // display arrives colourless and is re-coloured from the store, instead
632 // of the colour living only on the display that was just thrown away.
633 // A no-op when nothing changed, which is the common case.
634 self.sync_colors_from_metadata();
635 }
636
637 /// Load a whole history document (a saved part file parses as one); the
638 /// engine now OWNS this recipe. Rolls to the last feature and builds it.
639 ///
640 /// The document's top-level `metadata` field (the Properties-panel
641 /// name-keyed store) is lifted out into [`Self::metadata`] before the feature
642 /// list is handed to the kernel — loading a part REPLACES the store wholesale
643 /// (a document with no `metadata` clears it), mirroring the previous metadata
644 /// manager's load semantics. Round-trips with [`Self::history_request_json`].
645 ///
646 /// The top-level `workbench` field (the ACTIVE-WORKBENCH id the save embedded
647 /// — see [`Self::history_request_json`]) is lifted off the kernel recipe the
648 /// same way and applied through [`Self::apply_settings_json`] — the SAME seam
649 /// the toolbar's workbench dropdown writes through — so the palette / context
650 /// offers / workbench buttons react to a restored workbench exactly as they
651 /// do to a manual switch (settings generation bump included). Tolerances:
652 ///
653 /// * a legacy document WITHOUT the field (or with a non-string value) leaves
654 /// the current workbench untouched — opening an old file never yanks the
655 /// user out of their workbench;
656 /// * an unknown/stale id is stored RAW (never an error): the settings layer
657 /// deliberately doesn't validate ids, and every consumer resolves through
658 /// the app-side registry's `resolve()`, which falls back to the default
659 /// workbench — so a file saved by a build with a workbench this build
660 /// doesn't know still opens cleanly;
661 /// * the restored id is deliberately NOT persisted to the settings blob —
662 /// that blob stays the user's boot preference; a document's workbench is
663 /// session-scoped (the next explicit dropdown change persists as usual).
664 pub fn set_history_json(&mut self, request_json: &str) -> Result<String, String> {
665 // A locked history (a PLM revision this user may not change) is not
666 // replaced either: a fresh `History` would arrive unlocked, which is the
667 // one door `History::lock` cannot hold by itself. Opening another
668 // document is a new engine, and a reload after checkout unlocks first.
669 if let Some(reason) = self.history.locked() {
670 return Err(format!("this document is read-only: {reason}"));
671 }
672 // A document switch is a wholesale model replacement: drop the incremental
673 // cache so the new model starts from a clean slate (no cross-document
674 // staleness, no unbounded cache growth across many opens). The roll/edit
675 // hot path (`rerun_history`) deliberately KEEPS the cache for instant
676 // rollback; this is the ONE place the full clear belongs.
677 brep_kernel::clear_history_cache();
678 // Reset the delta runner's baseline in lockstep with the cache clear so the
679 // new document is a FULL rebuild (no reuse against the prior model's names).
680 self.runner.reset();
681 self.pending_mesh_imports.clear();
682 self.mesh_preview_results.clear();
683 self.pending_topology.clear();
684 self.topology_asked.clear();
685 self.scene.clear_exact_solids();
686 // A probed-but-unconsumed STEP assembly belongs to the document being
687 // replaced: importing it into the NEW one would land a file the user never
688 // chose here (and hold its solids resident until they did). A probe
689 // still running is likewise the old document's: its answer is dropped.
690 self.pending_step_assembly = None;
691 self.pending_step_probes.clear();
692 self.step_probe_results.clear();
693 // Everything the previous document's run shipped belongs to THAT
694 // document: the assembly tail, the component projection, its consumed
695 // names, and the main-side session's generation stamp. The new
696 // document's first reply replaces them; until it lands, nothing must
697 // read the old one's.
698 self.assembly_sync = None;
699 self.assembly_components.clear();
700 self.assembly_session_generation = None;
701 self.consumed_names.clear();
702 // The open sheet, the open sheet form and the projection cache all
703 // name objects of the document being replaced.
704 self.forget_sheets();
705 // A preview belongs to the document being replaced.
706 self.expression_preview = None;
707 let mut document: serde_json::Value = serde_json::from_str(request_json)
708 .map_err(|error| format!("history parse: {error}"))?;
709 // Pull `metadata` out of the document so the engine holds the single copy
710 // (kept off the History recipe the kernel executes).
711 let metadata_value = document
712 .as_object_mut()
713 .and_then(|object| object.remove("metadata"));
714 self.metadata.load_json(metadata_value.as_ref());
715 // Lift the saved active-workbench id off the kernel recipe (`metadata`'s
716 // sibling — the kernel would ignore the extra field, but the engine owns
717 // it) and apply it through the shared settings seam; see the doc comment
718 // for the legacy/unknown-id tolerances. Applied BEFORE the rebuild below
719 // so anything reading the settings post-run already sees the restored id.
720 if let Some(workbench_id) = document
721 .as_object_mut()
722 .and_then(|object| object.remove("workbench"))
723 .as_ref()
724 .and_then(serde_json::Value::as_str)
725 {
726 let _ = self.apply_settings_json(
727 &serde_json::json!({ "workbench": workbench_id }).to_string(),
728 );
729 }
730 // Stamp each sketch's per-loop ids onto its geometries before the model
731 // is built. Deriving already yields the right ids, so this renames
732 // nothing — it PERSISTS them, which is what lets a loop keep its identity
733 // when the edge the id came from is deleted. Doing it here (rather than
734 // only on sketch commit) covers every document, including one built by a
735 // script that never enters sketch mode. See the kernel's
736 // `features/sketch/loop_ids`.
737 stamp_sketch_loop_ids(&mut document);
738 // SEED this thread's kernel parts library from the document's block.
739 // The per-run request no longer carries the block (see
740 // `History::parts_library`), so this explicit install is the ONE door
741 // that seeds a loaded document — it is what `parts_library_json()`
742 // (SAVE), the mutation-path session re-run and the export lanes all
743 // resolve ACOMPs against, and it bumps the revision so the next run
744 // hands the fresh library to the background runner. A run that heals or
745 // GCs it ships the result back (`apply_run_output`).
746 let library = document
747 .get("partsLibrary")
748 .and_then(|block| serde_json::from_value(block.clone()).ok())
749 .unwrap_or_default();
750 brep_kernel::install_parts_library(&library);
751 self.history = History::from_request_json(&document.to_string())?;
752 Ok(self.rerun_history())
753 }
754
755 /// The whole history request document (persistence / debugging), with the
756 /// engine-owned extras folded back in on top of the kernel recipe so
757 /// save→open round-trips them:
758 ///
759 /// * `metadata` — the Properties-panel store, written only when non-empty so
760 /// an un-annotated model persists as before;
761 /// * `workbench` — the CURRENT active-workbench id
762 /// (`self.settings.workbench`), ALWAYS written so a saved part reopens in
763 /// the workbench it was saved from (restored by
764 /// [`Self::set_history_json`]). Always-embed keeps the invariant simple:
765 /// the serialized field tracks the LIVE setting, never a stale stored copy
766 /// — the load lifts it off the kernel recipe entirely, so this is the ONE
767 /// place it is (re)written. Note the deliberate consequence: switching
768 /// workbench changes this document, so the file panel's dirty flag flips —
769 /// consistent with the `metadata` precedent, and semantically true now
770 /// that the workbench is part of the saved file.
771 pub fn history_request_json(&self) -> String {
772 let mut document: serde_json::Value =
773 serde_json::from_str(&self.history.request_json())
774 .unwrap_or_else(|_| serde_json::json!({}));
775 if let Some(object) = document.as_object_mut() {
776 if !self.metadata.is_empty() {
777 object.insert("metadata".into(), self.metadata.to_json());
778 }
779 object.insert(
780 "workbench".into(),
781 serde_json::Value::String(self.settings.workbench.clone()),
782 );
783 }
784 document.to_string()
785 }
786
787 /// The tree listing `{ step, features:[{index,type,id}] }` for the UI panel.
788 pub fn history_listing_json(&self) -> String {
789 self.history.listing_json()
790 }
791
792 /// The last build report JSON.
793 pub fn history_report_json(&self) -> String {
794 self.history_report.clone()
795 }
796
797 pub fn history_len(&self) -> usize {
798 self.history.len()
799 }
800
801 /// The rolled-to (selected) feature index.
802 pub fn history_rollback(&self) -> usize {
803 self.history.rollback()
804 }
805
806 /// A monotonic counter of mutation opportunities on the history document —
807 /// the cache key for anything derived from it. See [`crate::history::History::revision`].
808 pub fn history_revision(&self) -> u64 {
809 self.history.revision()
810 }
811
812 pub fn feature_type_at(&self, index: usize) -> Option<String> {
813 self.history.feature_type(index)
814 }
815
816 pub fn feature_id_at(&self, index: usize) -> Option<String> {
817 self.history.feature_id(index)
818 }
819
820 /// The `inputParams` document of feature `index` (`"null"` if none) — the
821 /// dialog's editing-buffer source.
822 pub fn feature_params_json(&self, index: usize) -> String {
823 self.history
824 .feature_params(index)
825 .map(|v| v.to_string())
826 .unwrap_or_else(|| "null".to_string())
827 }
828
829 /// Mint the id for a NEW feature: `{base}{N}` where `base` is the feature's
830 /// shortName ([`crate::features::feature_short_name`]) and `N` is the part
831 /// history's persistent GLOBAL counter (monotonic, never reused, round-trips
832 /// save/load — see [`History::next_feature_id`]). `&mut` because the counter
833 /// advances; if the caller's `add_feature` then fails the number is simply
834 /// skipped (monotonic-with-gaps is the contract, not an error).
835 pub fn next_feature_id(&mut self, base: &str) -> String {
836 self.history.next_feature_id(base)
837 }
838
839 /// Adopt an edited document as one undo step and rebuild through the normal
840 /// runner, preserving the tab's scene settings and history stack.
841 pub fn edit_document_json(&mut self, document: &str) -> Result<String, String> {
842 let value: serde_json::Value = serde_json::from_str(document).map_err(|e| e.to_string())?;
843 let library = value.get("partsLibrary").map(|v| serde_json::from_value(v.clone()))
844 .transpose().map_err(|e| format!("parts library: {e}"))?;
845 self.history.adopt_document_checkpointed(document)?;
846 // The runner's library channel reads the resident store. Updating only
847 // History left it serving the old snapshot after Update Components.
848 if let Some(library) = library {
849 brep_kernel::install_parts_library(&library);
850 }
851 Ok(self.rerun_history())
852 }
853
854 /// Publish derived changes folded into the current user edit, without adding
855 /// another undo step (for example PCB placement and its assembly pose).
856 pub fn rebuild_current_history(&mut self) -> String {
857 self.rerun_history()
858 }
859
860 /// Roll the model to feature `index`: re-run `features[0..=index]`.
861 pub fn roll_to(&mut self, index: usize) -> String {
862 self.history.set_rollback(index);
863 self.rerun_history()
864 }
865
866 /// Replace feature `id`'s input params and re-run at the current rollback →
867 /// the viewport updates live.
868 pub fn update_feature_params(
869 &mut self,
870 id: &str,
871 input_params_json: &str,
872 ) -> Result<String, String> {
873 let mut params: serde_json::Value = serde_json::from_str(input_params_json)
874 .map_err(|e| format!("feature params parse: {e}"))?;
875 let index = self
876 .history
877 .index_of(id)
878 .ok_or_else(|| format!("no feature with id '{id}'"))?;
879 self.stamp_face_transform_pivot(index, &mut params);
880 self.history.set_feature_params(index, params);
881 Ok(self.rerun_history())
882 }
883
884 /// Replace the `inputParams` of MANY features as ONE model edit — the
885 /// [`Self::update_feature_params`] batch sibling ([`Self::add_features`] is
886 /// the append-only one). ONE undo checkpoint and ONE history re-run for the
887 /// whole set.
888 ///
889 /// The lane that needs it: a PACKED BOM row rolls up every occurrence whose
890 /// occurrence data matches, so editing one of its cells writes the same key
891 /// into N ACOMP features. Looping `update_feature_params` would cost N
892 /// re-runs and — worse — N undo entries, so taking back one visible edit
893 /// would need N presses of undo.
894 ///
895 /// Unknown ids are reported (the whole batch is refused before anything is
896 /// written, so a typo can never half-apply); an empty batch is a no-op that
897 /// neither checkpoints nor runs.
898 pub fn update_many_feature_params(
899 &mut self,
900 edits: &[(String, serde_json::Value)],
901 ) -> Result<String, String> {
902 if edits.is_empty() {
903 return Ok(self.history_report.clone());
904 }
905 let mut resolved: Vec<(usize, serde_json::Value)> = Vec::with_capacity(edits.len());
906 for (id, params) in edits {
907 let index = self
908 .history
909 .index_of(id)
910 .ok_or_else(|| format!("no feature with id '{id}'"))?;
911 let mut params = params.clone();
912 self.stamp_face_transform_pivot(index, &mut params);
913 resolved.push((index, params));
914 }
915 self.history.set_many_feature_params(&resolved);
916 Ok(self.rerun_history())
917 }
918
919 /// Append a feature (a full `{type, inputParams, …}` descriptor) and roll to
920 /// it. The caller assigns a unique `id` (see [`Self::next_feature_id`]).
921 pub fn add_feature(&mut self, feature_json: &str) -> Result<String, String> {
922 let feature: serde_json::Value =
923 serde_json::from_str(feature_json).map_err(|e| format!("feature parse: {e}"))?;
924 self.history.push_feature(feature);
925 let last = self.history.len().saturating_sub(1);
926 self.history.set_rollback(last);
927 Ok(self.rerun_history())
928 }
929
930 /// Append MANY features and roll to the last — [`Self::add_feature`]'s batch
931 /// sibling, and the reason it exists: `add_feature` re-runs the WHOLE history
932 /// per call, so a lane that appends N features by looping it costs N rebuilds
933 /// (O(N²) work on an N-part STEP-assembly import). This pushes all of them,
934 /// then re-runs ONCE — one rebuild, one undo checkpoint, one
935 /// [`applied_generation`](Self::applied_generation) bump.
936 ///
937 /// Deliberately NOT [`Self::set_history_json`]: that is the document-SWITCH
938 /// path (it clears the kernel history cache and resets the runner's delta
939 /// baseline, forcing a full cold rebuild), which is the wrong mechanism for an
940 /// append onto the live document.
941 ///
942 /// An empty batch is a no-op — no checkpoint, no run, no generation bump —
943 /// and returns the standing report. The caller assigns each feature's unique
944 /// `id` (see [`Self::next_feature_id`]).
945 pub fn add_features(&mut self, features: &[serde_json::Value]) -> String {
946 if features.is_empty() {
947 return self.history_report.clone();
948 }
949 self.history.push_features(features.to_vec());
950 let last = self.history.len().saturating_sub(1);
951 self.history.set_rollback(last);
952 self.rerun_history()
953 }
954
955 /// Delete the feature with id `id` (no-op if absent) and re-run, clamping the
956 /// rolled-to step.
957 pub fn delete_feature(&mut self, id: &str) -> String {
958 match self.history.index_of(id) {
959 Some(index) => self.delete_feature_at(index),
960 None => self.rerun_history(),
961 }
962 }
963
964 /// Delete the feature at `index` (no-op when out of range) and re-run,
965 /// clamping the rolled-to step.
966 ///
967 /// The POSITIONAL twin of [`Self::delete_feature`], and the only way to
968 /// remove a feature whose `inputParams` carry no `id` — a shape a hand-built
969 /// history JSON can still contain, and one an id-keyed delete can never
970 /// address.
971 pub fn delete_feature_at(&mut self, index: usize) -> String {
972 if index < self.history.len() {
973 self.history.remove_feature(index);
974 let step = self
975 .history
976 .rollback()
977 .min(self.history.len().saturating_sub(1));
978 self.history.set_rollback(step);
979 }
980 self.rerun_history()
981 }
982
983 /// Move feature `index` one slot up/down (reorder), keeping it selected.
984 pub fn reorder_feature(&mut self, index: usize, up: bool) -> String {
985 let len = self.history.len();
986 if len >= 2 {
987 let target = if up {
988 index.checked_sub(1)
989 } else if index + 1 < len {
990 Some(index + 1)
991 } else {
992 None
993 };
994 if let Some(target) = target {
995 self.history.swap(index, target);
996 self.history.set_rollback(target);
997 }
998 }
999 self.rerun_history()
1000 }
1001
1002}
1003
1004impl EngineState {
1005 /// Whether an undo step is available (to enable the toolbar's Undo button).
1006 pub fn can_undo(&self) -> bool {
1007 self.history.can_undo()
1008 }
1009
1010 /// Whether a redo step is available.
1011 pub fn can_redo(&self) -> bool {
1012 self.history.can_redo()
1013 }
1014
1015 /// Undo the last model mutation: restore the previous document + rolled-to
1016 /// step, then re-run + reconcile the scene. Returns the build report; a no-op
1017 /// (empty undo stack) returns the last report unchanged.
1018 pub fn undo(&mut self) -> String {
1019 if self.history.undo() {
1020 self.reinstall_rewound_parts_library();
1021 self.rerun_history()
1022 } else {
1023 self.history_report.clone()
1024 }
1025 }
1026
1027 /// Redo the last undone model mutation (symmetric with [`Self::undo`]).
1028 pub fn redo(&mut self) -> String {
1029 if self.history.redo() {
1030 self.reinstall_rewound_parts_library();
1031 self.rerun_history()
1032 } else {
1033 self.history_report.clone()
1034 }
1035 }
1036
1037 /// Push the just-rewound `partsLibrary` block back into the kernel's
1038 /// main-side store, so time travel moves the LIBRARY with the document.
1039 ///
1040 /// Without this, undo only half-works on anything that edits a library
1041 /// entry. The undo snapshot carries the block (`History::Snapshot`), but the
1042 /// block is a MIRROR: the per-run request does not carry it
1043 /// ([`History::prefix_request`] omits it) and a run that heals or GCs the
1044 /// store writes its result back over the block (`apply_run_output`). So a
1045 /// rewound block that was never pushed into the store is simply overwritten
1046 /// again, and the undo silently does nothing — which is what
1047 /// [`Self::set_part_attribute`](crate::engine_state::EngineState::set_part_attribute)
1048 /// would hit on its first undo.
1049 ///
1050 /// `install_parts_library` matches the store to the block BY CONTENT
1051 /// IDENTITY: an identical entry is kept verbatim (so a heal this side
1052 /// derived is not clobbered), a changed one is replaced and marked dirty
1053 /// (so the ACOMP self-heal re-derives every instance), and one absent from
1054 /// the block is dropped. An unchanged block is therefore a no-op, which is
1055 /// the overwhelmingly common undo.
1056 pub(super) fn reinstall_rewound_parts_library(&mut self) {
1057 let library = serde_json::from_value(self.history.parts_library().clone())
1058 .unwrap_or_default();
1059 brep_kernel::install_parts_library(&library);
1060 }
1061
1062 // --- Selection (Esc clears / viewport click selects) ------------------
1063
1064}
1065
1066/// Stamp per-loop ids onto every SKETCH feature's geometries in a history
1067/// document, in place.
1068///
1069/// The persistence half of per-loop face naming. The kernel DERIVES a loop's id
1070/// the same way every run, so this renames nothing; what it adds is durability —
1071/// a stored id survives deleting the edge it was originally derived from, which
1072/// derivation alone cannot. Running it on document LOAD (not only on sketch
1073/// commit) means a model built by a script, an import, or any other path that
1074/// never opens the sketch editor still gets its ids written down.
1075///
1076/// Assemblies: a parts-library entry embeds a FULL sub-part history
1077/// (`partsLibrary[*].document`), whose features can include sketches of its own,
1078/// so the walk recurses into each one. Without that, a sketch inside an imported
1079/// assembly part would derive correct names but carry no stored ids — exactly the
1080/// case (deleting the edge an id came from) that persisting exists to cover.
1081/// Depth is bounded: a sub-document's own library is seeded from the kernel store
1082/// rather than nested inside the entry, so one level of recursion reaches all of
1083/// them, and `MAX_LIBRARY_DEPTH` stops a malformed self-referential document.
1084///
1085/// Malformed features are skipped rather than rejected: this is a best-effort
1086/// enrichment on the way to the kernel, which validates the document itself.
1087fn stamp_sketch_loop_ids(document: &mut serde_json::Value) {
1088 /// Depth cap for the embedded sub-document walk — a guard against a
1089 /// hand-edited or corrupt document that nests libraries into each other.
1090 const MAX_LIBRARY_DEPTH: usize = 8;
1091 stamp_sketch_loop_ids_to_depth(document, MAX_LIBRARY_DEPTH);
1092}
1093
1094/// [`stamp_sketch_loop_ids`] with the remaining recursion budget.
1095fn stamp_sketch_loop_ids_to_depth(document: &mut serde_json::Value, depth: usize) {
1096 if let Some(features) = document
1097 .get_mut("features")
1098 .and_then(serde_json::Value::as_array_mut)
1099 {
1100 for feature in features {
1101 if feature.get("type").and_then(serde_json::Value::as_str) != Some("S") {
1102 continue;
1103 }
1104 let Some(sketch) = feature
1105 .get_mut("persistentData")
1106 .and_then(|data| data.get_mut("sketch"))
1107 else {
1108 continue;
1109 };
1110 brep_kernel::assign_sketch_loop_ids(sketch);
1111 }
1112 }
1113 if depth == 0 {
1114 return;
1115 }
1116 // Each library entry's embedded sub-part history gets the same treatment.
1117 let Some(library) = document
1118 .get_mut("partsLibrary")
1119 .and_then(serde_json::Value::as_object_mut)
1120 else {
1121 return;
1122 };
1123 for (_, entry) in library.iter_mut() {
1124 let Some(embedded) = entry.get_mut("document") else {
1125 continue;
1126 };
1127 stamp_sketch_loop_ids_to_depth(embedded, depth - 1);
1128 }
1129}
1130
1131impl EngineState {
1132 /// Load a whole model document (a saved `.nbrep` recipe) and FRAME it:
1133 /// [`set_history_json`](Self::set_history_json) (which rolls to the last
1134 /// feature) followed by [`zoom_to_fit`](Self::zoom_to_fit). The one call the
1135 /// file panel's **Open** needs — the model IS the engine-owned history, so
1136 /// opening a file is loading its request JSON and reframing. Returns the
1137 /// build-report JSON.
1138 pub fn load_model_and_fit(&mut self, request_json: &str) -> Result<String, String> {
1139 // Frame once the run lands, not now: under a background runner (native
1140 // thread / wasm worker) the freshly loaded model is not resident yet when
1141 // `set_history_json` returns, so an immediate `zoom_to_fit` would frame the
1142 // OLD scene. Set BEFORE the submit so the Inline runner's in-call `pump`
1143 // still frames synchronously. See [`EngineState::pending_fit`].
1144 self.pending_fit = true;
1145 let result = self.set_history_json(request_json);
1146 if result.is_err() {
1147 // A rejected document submits no run, so the armed fit would otherwise
1148 // fire on the OLD scene next frame — disarm it.
1149 self.pending_fit = false;
1150 }
1151 result
1152 }
1153}
1154
1155
1156
1157
1158
1159
1160/// The run report's `featureRefusals` object, added only when a feature failed
1161/// with a TYPED refusal: feature id → the kernel's refusal as it serializes
1162/// (`class` and its payload, `stage`, `message` — the text `featureErrors`
1163/// carries for the same feature) plus `step`, the motion step that refused,
1164/// when the feature records one (Transform Face: `rotation` / `translation`).
1165/// Absent otherwise, so a report with no typed refusal is byte-for-byte what it
1166/// was before this key existed.
1167fn with_refusals(
1168 mut value: serde_json::Value,
1169 report: &crate::pipeline::SceneBuildReport,
1170) -> serde_json::Value {
1171 if report.feature_refusals.is_empty() {
1172 return value;
1173 }
1174 let refusals: serde_json::Map<String, serde_json::Value> = report
1175 .feature_refusals
1176 .iter()
1177 .map(|(id, refusal, step)| {
1178 let mut value = serde_json::to_value(refusal).unwrap_or(serde_json::Value::Null);
1179 if let (Some(step), serde_json::Value::Object(object)) = (step, &mut value) {
1180 object.insert("step".into(), serde_json::Value::String(step.clone()));
1181 }
1182 (id.clone(), value)
1183 })
1184 .collect();
1185 if let serde_json::Value::Object(object) = &mut value {
1186 object.insert("featureRefusals".into(), serde_json::Value::Object(refusals));
1187 }
1188 value
1189}
1190
1191/// The run report's `featureFulfilment` object: feature id → the typed
1192/// fulfilment (`requested`, `applied`, `rejected[{name, kind, reason}]`) plus a
1193/// one-line `summary` the tree and the automation report show verbatim. Only the
1194/// features that reported one appear, so an absent key means "did everything".
1195fn fulfilment_map(report: &crate::pipeline::SceneBuildReport) -> serde_json::Map<String, serde_json::Value> {
1196 report
1197 .feature_fulfilment
1198 .iter()
1199 .map(|(id, fulfilment)| {
1200 let mut value = serde_json::to_value(fulfilment).unwrap_or(serde_json::Value::Null);
1201 if let serde_json::Value::Object(object) = &mut value {
1202 object.insert("summary".into(), serde_json::Value::String(fulfilment.summary()));
1203 }
1204 (id.clone(), value)
1205 })
1206 .collect()
1207}