BREP_render 0.13.0

BREP Rust rendering engine: kernel-fed scene store + wgpu renderer (headless artifact, desktop window, and wasm canvas shells).
Documentation
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
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
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 = self.run_plugin_scene(&request)?;
        self.dirty = true;
        Ok(with_auto_targets(with_approximations(
            with_refusals(
                serde_json::json!({
                    "featureErrors": report.feature_errors,
                    "featureNotes": report.feature_notes,
                    "featureFulfilment": fulfilment_map(&report),
                    "unresolved": report.unresolved,
                    "displayErrors": report.display_errors,
                }),
                &report,
            ),
            &report,
        ), &report)
        .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 {
        self.rerun_history_forcing(None)
    }

    pub(super) fn rerun_history_forcing(&mut self, feature_id: Option<&str>) -> String {
        if self.defer_plugin_run() { return self.history_report.clone(); }
        // This run publishes whatever a symbol edit did to the PORT features,
        // so `pump` need not run again for it.
        self.history.take_ports_followed();
        // 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.
        // A transient preview replaces what the run evaluates, in the request
        // only: the history keeps its own source.
        self.drop_stale_expression_preview();
        let request_value = self.run_request_value();
        match serde_json::from_value::<HistoryRequest>(request_value) {
            Ok(mut request) => {
                if let Some(id) = feature_id {
                    request.force_rebuild.push(id.to_string());
                }
                // 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.sync_plugin_runner();
                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.ports_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` 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) {
        self.pump_with(false);
    }

    /// [`pump`](Self::pump) leaving a run reply, and a refusal asking for the
    /// parts library, for a later pump: everything else drains. For a
    /// background document whose library is not installed this frame (see
    /// [`run_landing`](Self::run_landing)); a reply that arrives meanwhile
    /// waits for the next frame, which installs the library first.
    pub fn pump_holding_runs(&mut self) {
        self.pump_with(true);
    }

    fn pump_with(&mut self, hold_runs: bool) {
        self.history.begin_frame();
        // A user edit refused because an agent's staged feature is evaluating
        // is said, once per refusal, rather than silently dropped.
        if self.history.busy_refused() > self.busy_refused_seen {
            self.busy_refused_seen = self.history.busy_refused();
            self.push_notice(format!(
                "Not applied: {}. Try again when it has finished.",
                self.history.busy().unwrap_or("an agent's change to this document is still evaluating")
            ));
        }
        self.pump_plugin_installations();
        // 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 !hold_runs && !self.library_resync && self.runner.poll_library_request() {
            self.library_resync = true;
            self.rerun_history();
            self.library_resync = false;
        }
        // A symbol edit relabelled, added or removed a PORT feature. The eCAD
        // editors write their blocks straight into the history, which does not
        // run it, so the run happens here, on the next frame, for every host.
        if self.history.take_ports_followed() {
            self.rerun_history();
        }
        // Phases arrive before their import's final reply; only a preview
        // asks for them.
        while let Some(phase) = self.runner.poll_mesh_import_phase() {
            if self.pending_mesh_imports.get(&phase.id) == Some(&MeshImportDestination::Preview) {
                self.mesh_preview_phases.push_back(phase);
            }
        }
        while let Some(reply) = self.runner.poll_region_fit() {
            self.region_fit_results.push_back(reply);
        }
        while let Some(reply) = self.runner.poll_apply_region_choices() {
            self.apply_choices_results.push_back(reply);
        }
        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",
                        );
                        // A body the chain refused is left out of the model;
                        // say so, by name, rather than let it vanish.
                        if let Some(notice) = crate::runner::refused_bodies_notice(&output.report)
                        {
                            self.push_notice(notice);
                        }
                    }
                    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);
            }
        }
        self.pump_plugin_callbacks();
        while let Some(reply) = if hold_runs { None } else { self.runner.poll_run() } {
            self.runs_replied += 1;
            if reply.generation >= self.run_generation && self.accept_plugin_run(&reply) {
                self.applied_generation = reply.generation;
                self.apply_run_output(reply.output);
            } else {
                // Superseded — but the runner's baseline has moved past every
                // display in it, so a later keep can name one of them. Hold
                // them for that keep (see `superseded_displays`).
                for (name, _handle, maybe) in reply.output.snapshot {
                    if let Some(display) = maybe {
                        self.superseded_displays.insert(name, display);
                    }
                }
            }
        }
        // The run just applied released sketch links the kernel could not
        // resolve, editing the history after the fact: run it again so the
        // report matches the document. Once, since the second run finds no
        // listed edge left to release (the inline runner applies it before
        // this returns; a background runner lands it on a later frame).
        if std::mem::take(&mut self.sketch_links_released) {
            self.rerun_history();
        }
        // A component replace classifies its mates once its run has landed.
        if self.replace_pending.is_some() {
            self.finish_component_replace();
        }
        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();
        }
        if self.instructions_wizard_pending && !self.run_pending() {
            self.instructions_wizard_pending = false;
            if let Err(error) = self.instructions_run_wizard() {
                self.push_notice(format!("The instructions wizard could not run: {error}"));
            }
        }
        if self.instructions_views_pending && !self.run_pending() {
            self.instructions_views_pending = false;
            if let Err(error) = self.instructions_refresh_views() {
                self.push_notice(format!("The re-targeted steps' views could not be made: {error}"));
            }
        }
        // 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();
        self.pump_topology();
        self.pump_sheet_lines();
        // The CAM generation queue: progress and replies, then the next job.
        self.cam_pump();
    }

    /// Ask the runner for the B-rep topology of the displayed solids `names`
    /// that the scene does not hold for their current handle and that have not
    /// been asked for since the last applied run, then drain whatever has
    /// already answered (all of it, for the synchronous Inline runner). The
    /// drawing sheet's door, and the balloon bubble drag's (which re-projects
    /// the arrow head on the exact solid, `pmi_reproject_balloon_head`):
    /// nothing else needs topology on this side, so a document with neither a
    /// sheet nor a dragged balloon never asks.
    pub fn request_exact_solids<'a>(&mut self, names: impl IntoIterator<Item = &'a str>) {
        let mut wanted: Vec<(String, u32)> = Vec::new();
        for name in names {
            let Some(display) = self.scene.solid(name) else { continue };
            let handle = display.source_handle;
            if handle == 0 || display.is_sketch || self.scene.exact_solid(name).is_some() {
                continue;
            }
            if self.topology_asked.insert(handle) {
                wanted.push((name.to_string(), handle));
            }
        }
        if !wanted.is_empty() {
            self.next_topology_id += 1;
            let id = self.next_topology_id;
            self.pending_topology.insert(id);
            self.runner.submit_topology(crate::runner::TopologyRequest { id, solids: wanted });
        }
        self.pump_topology();
    }

    /// Fold every answered topology request into the scene. A reply for a
    /// display that has moved on to another handle is dropped; the next
    /// projection asks for the new one.
    pub fn pump_topology(&mut self) {
        let mut landed = false;
        while let Some(reply) = self.runner.poll_topology() {
            if !self.pending_topology.remove(&reply.id) {
                continue; // cancelled, or a document switch
            }
            for (name, handle, solid) in reply.solids {
                self.scene.set_exact_solid(&name, handle, solid);
            }
            landed = true;
        }
        if landed {
            self.pmi_refresh_stale_balloon();
        }
    }

    /// Whether a topology request is still in flight — the frame loop and the
    /// idle contract wait on it like a measurement query.
    pub fn topology_pending(&self) -> bool {
        !self.pending_topology.is_empty()
    }

    /// 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 the run in flight has something to land this frame: its reply,
    /// a refusal to re-submit, or plugin work (which is not told apart). A
    /// background document's [`pump`](Self::pump) needs its own parts
    /// library installed only then.
    pub fn run_landing(&mut self) -> bool {
        self.run_pending() && (self.plugin_work_pending() || self.runner.run_reply_waiting())
    }

    /// 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 || self.plugin_work_pending()
    }

    /// 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 {
        self.cancel_run_with_notice(true)
    }

    pub(super) fn cancel_run_with_notice(&mut self, notify: bool) -> bool {
        if self.plugin_installation_pending() { return false; }
        if self.cancel_plugin_action() { return true; }
        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();
        // The new runner's registry counts handles from one again, so every
        // topology keyed by an old handle could alias a new display.
        self.pending_topology.clear();
        self.topology_asked.clear();
        self.scene.clear_exact_solids();
        self.sheet_lines.forget();
        // The old runner's held displays name ITS handles; the new one's
        // first run emits every display afresh.
        self.superseded_displays.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.instructions_wizard_pending = false;
        self.instructions_views_pending = false;
        self.library_resync = false;
        self.sketch_links_released = false;
        self.cancelled_run = Some(stalled_on.clone().unwrap_or_default());
        if notify {
            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
    }

    /// How many run replies the runner has sent back (see `runs_replied`).
    pub fn runs_replied(&self) -> u64 {
        self.runs_replied
    }

    /// 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.plugin_runner_replaced();
        self.runner.reset();
        self.superseded_displays.clear();
        self.pending_mesh_imports.clear();
        self.mesh_preview_results.clear();
        self.pending_topology.clear();
        self.topology_asked.clear();
        self.scene.clear_exact_solids();
        self.sheet_lines.forget();
    }

    /// 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.
    pub(super) fn apply_run_output(&mut self, output: crate::pipeline::RunOutput) {
        let crate::pipeline::RunOutput {
            snapshot,
            mut report,
            provenance,
            entity_origin,
            assembly_poses,
            assembly_fixed,
            moved_solids,
            imported_colors,
            consumed,
            assembly,
        } = output;
        for (id, data) in &report.plugin_persistent_data {
            self.history.fold_plugin_persistent_data(id, data.clone());
        }
        // 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. A live component drag's poses go first
        // (they were applied last).
        self.component_live_restore();
        self.pmi_restore_explode();
        // A motion study's posed frame goes too (re-posed after apply).
        self.simulation_restore_displays();

        // The names this run consumed, for the committed-sketch refresh in the
        // tail below — the runner folded them, so the UI thread does not execute
        // the history a second time to fold them again.
        self.consumed_names = consumed.into_iter().collect();
        // Adopt the run's assembly tail. The PARTS LIBRARY comes out of it here
        // rather than in `adopt_assembly_sync`, so the (potentially megabyte)
        // store is installed and mirrored into the document exactly once, on the
        // run that moved it, and never kept in the snapshot afterwards.
        self.assembly_sync = assembly.map(|mut sync| {
            if let Some(library) = sync.parts_library.take() {
                // CLEAR, then install. `install_parts_library` keeps a resident
                // entry whose identity (source key + signature + document hash)
                // matches the incoming one VERBATIM — the right rule for the
                // main → runner direction it was written for, because the
                // runner may hold a heal the sender never saw. This is the
                // other direction: the run that just healed an entry (a heal
                // rewrites the snapshot and deliberately does NOT change the
                // identity) is the one holding the newer copy, so keeping the
                // resident one would silently drop it and the document would
                // re-heal on every open. Emptying first makes every incoming
                // entry a fresh insert, i.e. "the store IS what the run left".
                brep_kernel::install_parts_library(&Default::default());
                brep_kernel::install_parts_library(&library);
                if let Ok(block) = serde_json::to_value(&library) {
                    self.history.set_parts_library(block);
                }
            }
            sync
        });

        // --- assembly pose-authority fold -------- 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.fold_assembly_pose(&id, pose);
        }
        // A legacy Fixed row migrated by the run: the instance setting it
        // became (`isFixed`, or `flexibleFixed[path]` on the flexible instance
        // that lends an occurrence `ACOMP1:ACOMP2`).
        for (id, fixed) in assembly_fixed {
            self.fold_assembly_fixed(&id, 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.superseded_displays.remove(&name);
                    self.scene.insert_solid(display);
                }
                // "Unchanged": the runner last emitted `handle` for this name.
                // That emit is the scene's display when its reply was applied,
                // and a held superseded display when it was dropped — the
                // handle says which, since handles are monotonic and never
                // recycled within one runner. Neither holding it is a protocol
                // breach (a reply lost between the runner and `pump`), and the
                // app is not the place to crash on one, in any build: the
                // solid is left out of this frame and the report names it.
                None => {
                    let kept_display = kept.remove(&name);
                    let display = match kept_display {
                        Some(display) if display.source_handle == handle => Some(display),
                        _ => self
                            .superseded_displays
                            .remove(&name)
                            .filter(|display| display.source_handle == handle),
                    };
                    match display {
                        Some(display) => self.scene.insert_solid(display),
                        None => {
                            report.display_errors.push(format!(
                                "{name}: the run kept resident handle {handle}, which no reply has displayed"
                            ));
                        }
                    }
                }
            }
        }
        // Topology is kept only for a display whose handle survived the run,
        // and so is the record of what was asked: a surviving handle is in
        // flight, answered or not in the registry, and every other is gone.
        self.scene.prune_exact_solids();
        let live: std::collections::HashSet<u32> =
            self.scene.solids().iter().map(|solid| solid.source_handle).collect();
        self.topology_asked.retain(|handle| live.contains(handle));

        // 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 ports tail's report, for the Qualify panel: the tail is not
        // a feature, so nothing else carries what it refused or could not resolve.
        self.ports_report = report.ports.clone();
        // ...and the PMI report (every view's resolved annotations).
        self.pmi_report = report.pmi.clone();
        self.fold_plugin_annotation_data();
        // 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 = with_auto_targets(with_approximations(
            with_refusals(
                serde_json::json!({
                    "featureErrors": report.feature_errors,
                    "featureNotes": report.feature_notes,
                    "featureFulfilment": fulfilment_map(&report),
                    "unresolved": report.unresolved,
                    "displayErrors": report.display_errors,
                    "featureTimings": timings,
                    "featureOutputs": outputs,
                }),
                &report,
            ),
            &report,
        ), &report)
        .to_string();
        self.release_unresolved_sketch_links(&report.unresolved);
        self.finish_apply(report_json);
    }

    /// Release every sketch edge link whose edge this run could not resolve —
    /// the kernel's own `unresolved` word, per feature, so an edge a LATER
    /// feature consumed (gone from the final scene, present where the sketch
    /// runs) is never mistaken for a lost one. For each such link the sketch
    /// keeps its geometry as ordinary fixed construction geometry
    /// ([`crate::sketch::external_ref::release_ref_json`]), drops the
    /// `externalRefs` entry and takes the name out of `projectedEdges`. A
    /// listed name that never materialized has nothing to keep and stays
    /// listed (the report keeps naming it).
    ///
    /// Folded like the assembly pose write-back: no undo checkpoint, so undoing
    /// the deletion that lost the edge restores the snapshot with the link
    /// intact, and no run from here (re-entrancy); the flag makes `pump` run
    /// once more. Nothing happens while a sketch is being edited (the editor
    /// owns its doc and the rolled-back run never runs it) or while the
    /// document is locked (a refused fold would be retried every run).
    fn release_unresolved_sketch_links(&mut self, unresolved: &[String]) {
        if unresolved.is_empty() || self.sketch_edit.is_some() || self.history.locked().is_some() {
            return;
        }
        let mut released_any = false;
        for index in 0..self.history.len() {
            if !self.history.feature_type(index).is_some_and(|t| matches!(t.as_str(), "S" | "SKETCH")) {
                continue;
            }
            let Some(id) = self.history.feature_id(index).filter(|id| !id.is_empty()) else {
                continue;
            };
            let Some(mut persistent) = self.history.feature_persistent_data(index) else {
                continue;
            };
            let mut refs: Vec<crate::sketch::ExternalRef> = persistent
                .get("externalRefs")
                .and_then(|v| serde_json::from_value(v.clone()).ok())
                .unwrap_or_default();
            if refs.is_empty() {
                continue;
            }
            // `"{id}: {name}"`, possibly with the kernel's rename hint appended.
            let lost: Vec<String> = refs
                .iter()
                .map(|r| r.edge_name.clone())
                .filter(|name| {
                    let line = format!("{id}: {name}");
                    unresolved
                        .iter()
                        .any(|u| *u == line || u.starts_with(&format!("{line} (renamed:")))
                })
                .collect();
            if lost.is_empty() {
                continue;
            }
            let mut sketch = persistent.get("sketch").cloned().unwrap_or(serde_json::Value::Null);
            for r in refs.iter().filter(|r| lost.contains(&r.edge_name)) {
                crate::sketch::external_ref::release_ref_json(&mut sketch, r);
            }
            refs.retain(|r| !lost.contains(&r.edge_name));
            if let Some(object) = persistent.as_object_mut() {
                if !sketch.is_null() {
                    object.insert("sketch".into(), sketch);
                }
                object.insert(
                    "externalRefs".into(),
                    serde_json::to_value(&refs).unwrap_or_else(|_| serde_json::Value::Array(Vec::new())),
                );
            }
            let listed = brep_kernel::reference_names(
                self.history.feature_params(index).as_ref().and_then(|p| p.get("projectedEdges")),
            );
            let kept: Vec<serde_json::Value> = listed
                .into_iter()
                .filter(|name| !lost.contains(name))
                .map(serde_json::Value::String)
                .collect();
            self.history.fold_plugin_persistent_data(&id, persistent);
            self.history.fold_param_no_undo(&id, "projectedEdges", serde_json::Value::Array(kept));
            released_any = true;
        }
        if released_any {
            self.sketch_links_released = true;
        }
    }

    /// 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 => {}
            }
        }
        // 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();
        // Rebuild the BOARD's 3D bodies from the `pcb` block, for the same reason
        // the sketch sheets are rebuilt here: the reconcile above kept only the
        // run's own outputs. A document with no `pcb` block returns immediately
        // inside.
        self.refresh_board_geometry();
        // Assembly documents: adopt the component projection and the solved
        // constraint state the runner shipped, and fold that state back into the
        // document (the pose-authority contract — see `assembly_ops`).
        // Componentless documents return immediately inside.
        self.adopt_assembly_sync();
        // Component selection needs the new member projection above, not the
        // previous run's assembly. This also completes a deferred Move Copy.
        // 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 assembly-constraint viewport overlays from the rows the
        // same reply carried (an inert no-op — empty group — for a document with
        // no assembly state). ORDER MATTERS: the overlay read must follow the
        // adopt.
        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();
        // A motion study being played or scrubbed: its frame back on the
        // fresh displays (or a re-bake if the document moved).
        self.simulation_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 new document: its toolpaths are not this one's.
        self.cam.clear();
        // A locked history (a PLM revision this user may not change) is not
        // replaced either: a fresh `History` would arrive unlocked, which is the
        // one door `History::lock` cannot hold by itself. Opening another
        // document is a new engine, and a reload after checkout unlocks first.
        if let Some(reason) = self.history.locked() {
            return Err(format!("this document is read-only: {reason}"));
        }
        // 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.superseded_displays.clear();
        self.pending_mesh_imports.clear();
        self.mesh_preview_results.clear();
        self.pending_topology.clear();
        self.topology_asked.clear();
        self.scene.clear_exact_solids();
        // 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();
        // Everything the previous document's run shipped belongs to THAT
        // document: the assembly tail, the component projection, its consumed
        // names, and the main-side session's generation stamp. The new
        // document's first reply replaces them; until it lands, nothing must
        // read the old one's.
        self.assembly_sync = None;
        self.assembly_components.clear();
        self.assembly_session_generation = None;
        self.consumed_names.clear();
        // The open sheet, the open sheet form and the projection cache all
        // name objects of the document being replaced.
        self.forget_sheets();
        // A motion study being played or scrubbed names that document's
        // studies and solids: its bake and posed displays go with it.
        self.simulation_forget_playback();
        // A preview belongs to the document being replaced.
        self.expression_preview = None;
        let mut document: serde_json::Value = serde_json::from_str(request_json)
            .map_err(|error| format!("history parse: {error}"))?;
        let thumbnail = document.as_object_mut().and_then(|o| o.remove("thumbnail"));
        self.thumbnail_cache.replace(None);
        // 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);
        // Mass / Material / Density are PART attributes: an older document
        // that stored them on occurrences has them lifted onto the part here,
        // before the library is installed, and the move is reported once.
        let migration_notices =
            super::part_mass::migrate_occurrence_part_attributes(&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 mutation-path session 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. A run that heals or
        // GCs it ships the result back (`apply_run_output`).
        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())?;
        self.document_thumbnail.replace(thumbnail.map(|image| (self.history.edit_serial(), image)));
        for notice in migration_notices {
            self.push_notice(notice);
        }
        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 {
        self.history_request_value().to_string()
    }

    /// [`Self::history_request_json`] as a value, for a caller that edits the
    /// document (Update components): nothing is printed or parsed.
    pub fn history_request_value(&self) -> serde_json::Value {
        let mut document = self.history.request_value();
        if let Some(object) = document.as_object_mut() {
            if !self.metadata.is_empty() {
                object.insert("metadata".into(), self.metadata.to_json());
            }
            object.insert("thumbnail".into(), self.model_thumbnail());
            object.insert(
                "workbench".into(),
                serde_json::Value::String(self.settings.workbench.clone()),
            );
        }
        document
    }

    /// Cache the small portable PNG by scene content, not by camera position.
    /// The picture is the DOCUMENT's, so it is rendered only while the scene is
    /// the document's model as built. A run the scene has not caught up with
    /// (an edit whose rebuild is still to come) gets an explicitly pending,
    /// empty image rather than a picture of the previous geometry; plugin work
    /// in flight is not that, since a candidate keeps its own scene until it
    /// commits. A transient presentation — an expression preview (a family
    /// member shown in the document's place) or an illustration — gets the
    /// document's own last picture (the one it was opened with, or the one its
    /// last save rendered) rather than what is on screen. A just-opened file
    /// keeps its embedded image while its unchanged recipe is being rebuilt.
    /// The document's picture is keyed by the history's edit serial, which
    /// moves on user edits, undo and redo only (a run's write-back ticks the
    /// revision). The same built model renders the same bytes every time.
    fn model_thumbnail(&self) -> serde_json::Value {
        let pending = self.run_generation != self.applied_generation();
        let transient = self.expression_preview.is_some() || self.illustration_snapshot.is_some();
        let mut key = vec![self.history.revision(), self.applied_generation(),
            self.scene.visibility_revision(), self.settings_generation, pending as u64, transient as u64];
        key.extend(self.scene.solids().iter().map(|s| s.revision));
        if let Some((old, image)) = self.thumbnail_cache.borrow().as_ref() {
            if *old == key { return image.clone(); }
        }
        let image = if pending || transient {
            self.document_thumbnail.borrow().as_ref().filter(|(serial, _)| *serial == self.history.edit_serial())
                .map(|(_, image)| image.clone())
                .unwrap_or_else(|| crate::thumbnail::empty_embedded("pending"))
        } else {
            let capture = crate::thumbnail::capture(&self.scene);
            let image = match crate::thumbnail::render_png(&capture, crate::thumbnail::SIZE) {
                Some(png) => crate::thumbnail::embedded_png(&png, crate::thumbnail::SIZE, "ready"),
                None => crate::thumbnail::empty_embedded("empty"),
            };
            self.document_thumbnail.replace(Some((self.history.edit_serial(), image.clone())));
            image
        };
        self.thumbnail_cache.replace(Some((key, image.clone())));
        image
    }

    /// 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()
    }

    /// A monotonic counter of mutation opportunities on the history document —
    /// the cache key for anything derived from it. See [`crate::history::History::revision`].
    pub fn history_revision(&self) -> u64 {
        self.history.revision()
    }

    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)
    }

    /// Adopt an edited document as one undo step and rebuild through the normal
    /// runner, preserving the tab's scene settings and history stack.
    ///
    /// Callers edit what [`Self::history_request_json`] gave them, which carries
    /// the session's `workbench` and `thumbnail`. Those are lifted off, as a load
    /// lifts them ([`Self::set_history_json`]): a copy kept inside the history
    /// goes stale at the next workbench switch or render and reads as an unsaved
    /// change of the document (and a stamped thumbnail made a freshly refreshed
    /// tab read dirty, which hid a reload that threw the refresh away).
    pub fn edit_document_json(&mut self, document: &str) -> Result<String, String> {
        let value: serde_json::Value = serde_json::from_str(document).map_err(|e| e.to_string())?;
        self.edit_document(value)
    }

    /// [`Self::edit_document_json`] for a caller that holds the document as a
    /// value: nothing is printed or parsed again (at 500 parts the library's
    /// text round trips were most of an Update Components commit).
    pub fn edit_document(&mut self, mut value: serde_json::Value) -> Result<String, String> {
        use serde::Deserialize;
        let library = value.get("partsLibrary").map(brep_kernel::PartsLibraryMap::deserialize)
            .transpose().map_err(|e| format!("parts library: {e}"))?;
        if let Some(object) = value.as_object_mut() {
            object.remove("workbench");
            object.remove("thumbnail");
        }
        // Instructions taking a new state of their assembly re-target in the
        // same undo step (refused while one waits for approval).
        let retargeted = self.is_instructions() && self.instructions_fold_retarget(&mut value)?;
        self.history.adopt_document_value_checkpointed(value)?;
        if retargeted {
            self.instructions_views_pending = true;
        }
        // The runner's library channel reads the resident store. Updating only
        // History left it serving the old snapshot after Update Components.
        if let Some(library) = library {
            brep_kernel::install_parts_library(&library);
        }
        Ok(self.rerun_history())
    }

    /// Publish derived changes folded into the current user edit, without adding
    /// another undo step (for example PCB placement and its assembly pose).
    pub fn rebuild_current_history(&mut self) -> String {
        self.rerun_history()
    }

    /// 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 mut 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}'"))?;
        brep_kernel::feature_pipeline::validate_literal_feature_params(&self.feature_type_at(index).unwrap_or_default(), &params)?;
        self.stamp_face_transform_pivot(index, &mut params);
        self.history.set_feature_params(index, params);
        Ok(self.rerun_history())
    }

    /// The pattern instance `name` (a copy solid, or a face/edge of one)
    /// belongs to: `(pattern feature id, instance number)`. For a part Pattern
    /// the numbers are the kernel's copy suffixes (`PAT1_3` is instance 3; the
    /// source is 1); for a Pattern Component, the instance component's own
    /// number (`APAT1.ACOMP3.i3:Part`, or the bare component id).
    pub fn pattern_instance_of(&self, name: &str) -> Option<(String, i64)> {
        let component = name.split(':').next().unwrap_or(name);
        if let Some((pattern, _seed, n)) = brep_kernel::pattern_component_instance(component) {
            return Some((pattern, n));
        }
        let (id, ty) = self.creating_feature(name)?;
        if ty != "PATTERN" {
            return None;
        }
        let tag = format!("{id}_");
        let number = name
            .strip_prefix(&tag)
            .or_else(|| name.rsplit_once(&format!("::{tag}")).map(|(_, rest)| rest))?;
        let digits: String = number.chars().take_while(char::is_ascii_digit).collect();
        digits.parse().ok().map(|n| (id, n))
    }

    /// The Pattern feature `id`'s suppressed instance numbers.
    pub fn pattern_suppressed(&self, id: &str) -> Vec<i64> {
        let params = self.history.index_of(id).and_then(|index| self.history.feature_params(index));
        brep_kernel::pattern_parse_suppressed(params.as_ref().and_then(|p| p.get("suppressed")))
    }

    /// Suppress (or unsuppress) instance `number` of the Pattern feature `id`:
    /// one parameter edit, one undo step. `None` for `number` with
    /// `suppressed == false` unsuppresses every instance.
    pub fn pattern_set_suppressed(&mut self, id: &str, number: Option<i64>, suppressed: bool) -> Result<(), String> {
        let index = self.history.index_of(id).ok_or_else(|| format!("no feature with id '{id}'"))?;
        if number == Some(1) && suppressed {
            return Err(format!(
                "instance 1 of {id} is its source (for a Pattern Component, the seed): hide or suppress the source instead"
            ));
        }
        let mut list = self.pattern_suppressed(id);
        match (number, suppressed) {
            (Some(n), true) if !list.contains(&n) => list.push(n),
            (Some(n), false) => list.retain(|&m| m != n),
            (None, false) => list.clear(),
            _ => return Ok(()),
        }
        list.sort_unstable();
        let mut params = self.history.feature_params(index).unwrap_or_else(|| serde_json::json!({}));
        let text = list.iter().map(i64::to_string).collect::<Vec<_>>().join(", ");
        params["suppressed"] = serde_json::Value::String(text);
        self.update_feature_params(id, &params.to_string()).map(|_| ())
    }

    /// 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}'"))?;
            let mut params = params.clone();
            brep_kernel::feature_pipeline::validate_literal_feature_params(&self.feature_type_at(index).unwrap_or_default(), &params)?;
            self.stamp_face_transform_pivot(index, &mut params);
            resolved.push((index, params));
        }
        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}"))?;
        brep_kernel::feature_pipeline::validate_literal_feature_params(feature["type"].as_str().unwrap_or_default(), &feature["inputParams"])?;
        self.refuse_duplicate_ids(std::slice::from_ref(&feature))?;
        if !self.append_plugin_features(std::slice::from_ref(&feature))? {
            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();
        }
        for feature in features {
            if let Err(error) = brep_kernel::feature_pipeline::validate_literal_feature_params(feature["type"].as_str().unwrap_or_default(), &feature["inputParams"]) {
                return serde_json::json!({"error":error}).to_string();
            }
        }
        if let Err(error) = self.refuse_duplicate_ids(features) {
            return serde_json::json!({"error":error}).to_string();
        }
        match self.append_plugin_features(features) {
            Ok(true) => {},
            Ok(false) => self.history.push_features(features.to_vec()),
            Err(error) => return serde_json::json!({"error":error}).to_string(),
        }
        let last = self.history.len().saturating_sub(1);
        self.history.set_rollback(last);
        self.rerun_history()
    }

    /// A feature id names its solid, its faces and every reference to it, so it
    /// is unique in the history: an appended feature whose `inputParams.id` is
    /// already used — by the history or by an earlier feature of the same
    /// append — is refused, naming the id. (Two features sharing an id made the
    /// name resolve to one solid in the kernel and to the other in the display
    /// and the measurements, depending on which run produced it.)
    fn refuse_duplicate_ids(&self, features: &[serde_json::Value]) -> Result<(), String> {
        let mut seen: Vec<&str> = Vec::new();
        for feature in features {
            let Some(id) = feature.get("inputParams").and_then(|p| p.get("id")).and_then(serde_json::Value::as_str) else {
                continue;
            };
            if id.is_empty() {
                continue;
            }
            if let Some(index) = self.history.index_of(id) {
                return Err(format!("feature id `{id}` is already used by feature {index}; choose another id (or omit it to have one assigned)"));
            }
            if seen.contains(&id) {
                return Err(format!("feature id `{id}` is given twice in one append"));
            }
            seen.push(id);
        }
        Ok(())
    }

    /// 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()
        }
    }

    /// Open an undo group on this document's history: every edit until
    /// [`Self::history_group_end`] is one undo step (a script's run).
    pub fn history_group_begin(&mut self) -> u64 {
        self.history.group_begin()
    }

    /// Close undo group `token`; with `rollback`, restore the document as it
    /// was when the group opened and re-run. Whether it rolled back.
    pub fn history_group_end(&mut self, token: u64, rollback: bool) -> Result<bool, String> {
        let rolled = self.history.group_end(token, rollback)?;
        if rolled {
            self.reinstall_rewound_parts_library();
            self.rerun_history();
        }
        Ok(rolled)
    }

    /// [`Self::undo`] without its run, for a caller that edits the document
    /// straight after (the Work part replacing a coalesced step): that edit's
    /// run is the one the document needs, and an undo run before it would be
    /// executed and thrown away. Whether there was a step to undo.
    pub fn undo_before_edit(&mut self) -> bool {
        let undone = self.history.undo();
        if undone {
            self.reinstall_rewound_parts_library();
        }
        undone
    }

    /// 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 a run that heals or GCs the
    /// store writes its result back over the block (`apply_run_output`). 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.
    pub(super) 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 `.nbrep` 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
    }
}






/// The run report's `featureRefusals` object, added only when a feature failed
/// with a TYPED refusal: feature id → the kernel's refusal as it serializes
/// (`class` and its payload, `stage`, `message` — the text `featureErrors`
/// carries for the same feature) plus `step`, the motion step that refused,
/// when the feature records one (Transform Face: `rotation` / `translation`).
/// Absent otherwise, so a report with no typed refusal is byte-for-byte what it
/// was before this key existed.
fn with_refusals(
    mut value: serde_json::Value,
    report: &crate::pipeline::SceneBuildReport,
) -> serde_json::Value {
    if report.feature_refusals.is_empty() {
        return value;
    }
    let refusals: serde_json::Map<String, serde_json::Value> = report
        .feature_refusals
        .iter()
        .map(|(id, refusal, step)| {
            let mut value = serde_json::to_value(refusal).unwrap_or(serde_json::Value::Null);
            if let (Some(step), serde_json::Value::Object(object)) = (step, &mut value) {
                object.insert("step".into(), serde_json::Value::String(step.clone()));
            }
            (id.clone(), value)
        })
        .collect();
    if let serde_json::Value::Object(object) = &mut value {
        object.insert("featureRefusals".into(), serde_json::Value::Object(refusals));
    }
    value
}

/// The run report's `featureApproximations` object, added only when a feature
/// SUCCEEDED carrying a MEASURED approximation: feature id → the kernel's
/// approximations as they serialize (`code`, `body`, `measured`, `bar`,
/// `volume_bound`, `edges[{edge_id, curve_ref, face_ref, surface_ref,
/// off_carrier_mm, origin}]`, `message`), each plus a one-line `summary` the
/// history tree shows as the feature's leaf. Absent otherwise, so a report with
/// no approximation is byte-for-byte what it was before this key existed — the
/// same contract as `featureRefusals`. Distinct from `featureRefusals` (the
/// result does not stand) and `featureFulfilment` (what was done of what was
/// asked): an approximation's result stands, and this says by how much it is
/// not exact.
fn with_approximations(
    mut value: serde_json::Value,
    report: &crate::pipeline::SceneBuildReport,
) -> serde_json::Value {
    if report.feature_approximations.is_empty() {
        return value;
    }
    let approximations: serde_json::Map<String, serde_json::Value> = report
        .feature_approximations
        .iter()
        .map(|(id, approximations)| {
            let entries: Vec<serde_json::Value> = approximations
                .iter()
                .map(|approximation| {
                    let mut value =
                        serde_json::to_value(approximation).unwrap_or(serde_json::Value::Null);
                    if let serde_json::Value::Object(object) = &mut value {
                        object.insert(
                            "summary".into(),
                            serde_json::Value::String(approximation_summary(approximation)),
                        );
                    }
                    value
                })
                .collect();
            (id.clone(), serde_json::Value::Array(entries))
        })
        .collect();
    if let serde_json::Value::Object(object) = &mut value {
        object.insert("featureApproximations".into(), serde_json::Value::Object(approximations));
    }
    value
}

/// The run report's `featureAutoTargets` object, added only when a feature's
/// boolean named no target and ran into the part's ONLY solid: feature id → that
/// solid's name. Absent otherwise, so a report with no implicit target is
/// byte-for-byte what it was before this key existed (the `featureRefusals`
/// contract). The feature panel labels the boolean's target field with it.
fn with_auto_targets(
    mut value: serde_json::Value,
    report: &crate::pipeline::SceneBuildReport,
) -> serde_json::Value {
    if report.feature_auto_targets.is_empty() {
        return value;
    }
    let targets: serde_json::Map<String, serde_json::Value> = report
        .feature_auto_targets
        .iter()
        .map(|(id, name)| (id.clone(), serde_json::Value::String(name.clone())))
        .collect();
    if let serde_json::Value::Object(object) = &mut value {
        object.insert("featureAutoTargets".into(), serde_json::Value::Object(targets));
    }
    value
}

/// One line for an approximation, for the history tree's leaf: the marker
/// word, the code, the body, the measurement against its bar and the volume
/// bound when the code implies one. Code-agnostic on purpose — the units of
/// `measured` are the code's (mm² for `import.shell_closure`), so only the
/// volume bound, which every code states in mm³, carries a unit here; the
/// kernel's `message` has the full text with units, faces and edges.
pub fn approximation_summary(approximation: &brep_kernel::Approximation) -> String {
    let ratio = if approximation.bar > 0.0 {
        format!(" ({:.1}x)", approximation.measured / approximation.bar)
    } else {
        String::new()
    };
    let bound = approximation
        .volume_bound
        .map(|bound| format!("; volume determined to ±{bound:.3e} mm³"))
        .unwrap_or_default();
    format!(
        "approximate ({}): {} measured {:.3e} against a bar of {:.3e}{ratio}{bound}",
        approximation.code, approximation.body, approximation.measured, approximation.bar
    )
}

/// The run report's `featureFulfilment` object: feature id → the typed
/// fulfilment (`requested`, `applied`, `rejected[{name, kind, reason}]`) plus a
/// one-line `summary` the tree and the automation report show verbatim. Only the
/// features that reported one appear, so an absent key means "did everything".
fn fulfilment_map(report: &crate::pipeline::SceneBuildReport) -> serde_json::Map<String, serde_json::Value> {
    report
        .feature_fulfilment
        .iter()
        .map(|(id, fulfilment)| {
            let mut value = serde_json::to_value(fulfilment).unwrap_or(serde_json::Value::Null);
            if let serde_json::Value::Object(object) = &mut value {
                object.insert("summary".into(), serde_json::Value::String(fulfilment.summary()));
            }
            (id.clone(), value)
        })
        .collect()
}

impl EngineState {
    /// Adopt one solver pose write-back without an undo entry: a component's
    /// pose into its ACOMP `transform` (unless expressions drive it); a
    /// flexible sub-assembly occurrence's (`ACOMP1:ACOMP2`, part-local) into
    /// its instance's `flexiblePoses`, never the child document.
    pub(crate) fn fold_assembly_fixed(&mut self, id: &str, fixed: bool) {
        let Some((owner, path)) = id.split_once(':') else {
            self.history.fold_param_no_undo(id, "isFixed", serde_json::Value::Bool(fixed));
            return;
        };
        let Some(index) = self.history.index_of(owner) else { return };
        let mut overrides = self
            .history
            .feature_params(index)
            .and_then(|params| params.get("flexibleFixed").cloned())
            .filter(serde_json::Value::is_object)
            .unwrap_or_else(|| serde_json::json!({}));
        overrides[path] = serde_json::Value::Bool(fixed);
        self.history.fold_param_no_undo(owner, "flexibleFixed", overrides);
    }

    pub(crate) fn fold_assembly_pose(&mut self, id: &str, pose: serde_json::Value) {
        if let Some((owner, path)) = id.split_once(':') {
            let Some(index) = self.history.index_of(owner) else { return };
            let mut poses = self
                .history
                .feature_params(index)
                .and_then(|params| params.get("flexiblePoses").cloned())
                .filter(serde_json::Value::is_object)
                .unwrap_or_else(|| serde_json::json!({}));
            poses[path] = pose;
            self.history.fold_param_no_undo(owner, "flexiblePoses", poses);
            return;
        }
        let expression_driven = self.history.index_of(id).and_then(|i| self.history.feature_params(i))
            .is_some_and(|p| super::batch::contains_expression(&p["transform"]));
        if !expression_driven { self.history.fold_param_no_undo(id, "transform", pose); }
    }
}