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