BREP_render 0.12.1

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
use super::*;
use serde_json::Value;

// Assembly sessions are thread-local. The runner reads its session at the end of
// every run and ships it (`pipeline::AssemblySync`); the main thread ADOPTS that
// — the component projection, the solved constraint state, the statuses, the DOF
// summary and the overlay rows — instead of executing the history a second time.
// Componentless histories ship nothing and the projection is cleared.
//
// The ONE main-side replay left is `ensure_assembly_session`: a constraint
// mutation auto-solves against the resident geometry of the thread that asks, and
// the kernel's registry is thread-local, so the session has to exist HERE. That
// is per USER constraint edit, not per run. Mutations then call the kernel ABI,
// adopt the result with an undo checkpoint, and re-snapshot so the panels read
// the mutation before the rerun's reply lands. Auto-solve reruns the display
// history; otherwise manual Solve does.

/// What a component-insert names: an EXISTING parts-library entry (skip the
/// store read — just add an instance) or a NEW part payload to hand to
/// `add_part_to_library` (which dedups by sourceKey+signature and returns the
/// EFFECTIVE entry name the instance must reference).
pub enum ComponentInsert<'a> {
    Existing {
        part_name: &'a str,
    },
    New {
        name: &'a str,
        source_key: &'a str,
        source_signature: &'a str,
        document_json: &'a str,
    },
}

/// A STABLE content signature of a document: a sorted-key JSON walk hashed
/// with the (fixed-key, cross-process-deterministic) std SipHash. The ONE
/// signature fn, at the ENGINE altitude so app-side and engine-side writers
/// share one copy: the app's insert flow (`panels::file`), the edit-in-place
/// Finish (`panels::assembly_edit::refresh_library_entry`) and the
/// update-components comparison (`panels::update_components`) all write/compare
/// a parts-library `sourceSignature` with THIS function — a freshly inserted,
/// unchanged part must always compare up-to-date, which a second copy would
/// break the moment either drifted.
/// Uses the kernel's shared sorted-key hash, also used for in-memory library
/// fingerprints. Invalid JSON retains the raw-text hashing fallback.
pub fn document_signature(doc_json: &str) -> String {
    use std::hash::{Hash, Hasher};
    let hash = match serde_json::from_str::<serde_json::Value>(doc_json) {
        Ok(value) => brep_kernel::stable_json_hash(&value),
        Err(_) => {
            let mut hasher = std::collections::hash_map::DefaultHasher::new();
            doc_json.hash(&mut hasher);
            hasher.finish()
        }
    };
    format!("{hash:016x}")
}

/// A nested occurrence's Fix/Float as the assembly solve reads it.
///
/// `top_params` is the top-level instance's `inputParams`, `rest` the
/// occurrence's path inside it (`ACOMP2`, `ACOMP1:ACOMP2`) and `document` the
/// definition that instance places. Only an occurrence the solve moves can be
/// fixed or freed here: every instance above it must be flexible (a rigid
/// sub-assembly moves as one body, its parts never move on their own). The
/// state is the instance's own `flexibleFixed[rest]`, else the grounding of
/// the definition that places it (an explicit `isFixed`, else the first
/// component, else a legacy Fixed row that definition still carries — an
/// embedded copy keeps it until the part is saved again), as
/// `assembly::flexible` decides it.
pub fn nested_fixed_state(top_params: &Value, rest: &str, document: &Value) -> Result<bool, String> {
    let acomp = |document: &'_ Value, id: &str| -> Option<Value> {
        document["features"].as_array()?.iter().find(|f| {
            matches!(f["type"].as_str(), Some("ACOMP" | "ASSEMBLY COMPONENT")) && f["inputParams"]["id"].as_str() == Some(id)
        }).cloned()
    };
    let rigid = |name: &str| {
        format!("'{name}' is a rigid sub-assembly: it moves as one body, so its parts are not fixed or freed here. Tick Flexible on it, or edit it as the Work part")
    };
    let segments: Vec<&str> = rest.split(':').collect();
    let (last, above) = segments.split_last().ok_or("empty occurrence path")?;
    let top_name = top_params["partName"].as_str().unwrap_or_default();
    if top_params["flexible"] != Value::Bool(true) {
        return Err(rigid(top_name));
    }
    let mut document = document;
    for id in above {
        let feature = acomp(document, id).ok_or_else(|| format!("component '{id}' is missing"))?;
        let name = feature["inputParams"]["partName"].as_str().unwrap_or_default().to_string();
        if feature["inputParams"]["flexible"] != Value::Bool(true) {
            return Err(rigid(&name));
        }
        document = &document["partsLibrary"][&name]["document"];
    }
    acomp(document, last).ok_or_else(|| format!("component '{last}' is missing"))?;
    if let Some(fixed) = top_params["flexibleFixed"][rest].as_bool() {
        return Ok(fixed);
    }
    let components: Vec<&Value> = document["features"]
        .as_array()
        .into_iter()
        .flatten()
        .filter(|f| matches!(f["type"].as_str(), Some("ACOMP" | "ASSEMBLY COMPONENT")))
        .collect();
    let feature = components.iter().find(|f| f["inputParams"]["id"].as_str() == Some(last));
    if let Some(fixed) = feature.and_then(|f| f["inputParams"]["isFixed"].as_bool()) {
        return Ok(fixed);
    }
    if components.first().and_then(|f| f["inputParams"]["id"].as_str()) == Some(last) {
        return Ok(true);
    }
    let fixed_by_constraint = document["assembly"]["constraints"].as_array().into_iter().flatten().any(|c| {
        c["type"].as_str() == Some("fixed")
            && c["inputParams"]["elements"].as_array().into_iter().flatten().filter_map(Value::as_str).any(|e| e.split(':').next() == Some(*last))
    });
    Ok(fixed_by_constraint)
}

/// Rigid inverse `[Rᵀ | −Rᵀ·t]` of a component pose — used to express a picked
/// WORLD point in COMPONENT-LOCAL coordinates (the vertex-ref contract).
fn rigid_inverse_point(transform: &brep_kernel::AffineTransform, world: [f64; 3]) -> [f64; 3] {
    let m = &transform.elements;
    let d = [world[0] - m[3], world[1] - m[7], world[2] - m[11]];
    [
        m[0] * d[0] + m[4] * d[1] + m[8] * d[2],
        m[1] * d[0] + m[5] * d[1] + m[9] * d[2],
        m[2] * d[0] + m[6] * d[1] + m[10] * d[2],
    ]
}

impl EngineState {
    // --- read surface ------------------------------------------------------

    /// Whether the current document is an ASSEMBLY document: any ACOMP-typed
    /// feature, or a present `assembly` constraint block. Componentless
    /// documents skip every main-side sync (zero cost for modeling files).
    pub fn history_has_assembly(&self) -> bool {
        let has_acomp = (0..self.history.len()).any(|index| {
            matches!(
                self.history.feature_type(index).as_deref(),
                Some("ACOMP") | Some("ASSEMBLY COMPONENT")
            )
        });
        has_acomp
            || self
                .history
                .assembly_block()
                .map(|block| !block.is_null())
                .unwrap_or(false)
    }

    /// The scene's component records (deterministic id order) as of the last
    /// applied run — the Assembly Structure tree's projection source. A VIEW
    /// over the scene, never an owning structure.
    pub fn assembly_components(&self) -> &[crate::pipeline::ComponentSnapshot] {
        &self.assembly_components
    }

    /// The assembly tail of the last applied run, or an empty stand-in for a
    /// componentless document / a document that has not run yet.
    fn assembly_sync(&self) -> std::borrow::Cow<'_, crate::pipeline::AssemblySync> {
        match &self.assembly_sync {
            Some(sync) => std::borrow::Cow::Borrowed(sync),
            None => std::borrow::Cow::Owned(crate::pipeline::AssemblySync::default()),
        }
    }

    /// The per-constraint status rows (`[{id, type, enabled, open, status,
    /// message, satisfied, error}]`) the run reported.
    pub fn assembly_statuses_value(&mut self) -> Value {
        match &self.assembly_sync().statuses {
            Value::Null => Value::Array(Vec::new()),
            value => value.clone(),
        }
    }

    /// The current `assembly` block (post-solve): `{constraints, idCounter}`.
    pub fn assembly_state_value(&mut self) -> Value {
        match &self.assembly_sync().state {
            // Before the first run there is no state at all; answer with the
            // empty block every caller expects rather than a bare null.
            Value::Null => serde_json::json!({ "constraints": [], "idCounter": 0 }),
            value => value.clone(),
        }
    }

    /// The last solve's DOF/diagnostics summary (`{ok, dof, rank, redundant,
    /// …}` — `movedSolids` may be absent; tolerate it).
    pub fn assembly_dof_value(&mut self) -> Value {
        self.assembly_sync().dof.clone()
    }

    /// Per-constraint overlay rows (world anchors/directions/status/value) —
    /// the constraints panel reads the evaluated value/unit for the
    /// distance/angle label suffix; lane G's viewport graphics read the rest.
    pub fn assembly_overlay_value(&mut self) -> Value {
        match &self.assembly_sync().overlay {
            Value::Null => Value::Array(Vec::new()),
            value => value.clone(),
        }
    }

    /// The parts-library entry names currently resident (insert-flow "existing
    /// entries first" list). The main-side store, seeded by document ingest and
    /// kept current by the runs that heal or GC it (`apply_run_output` installs
    /// the store a run moved), and grown by [`insert_component`](Self::insert_component).
    pub fn parts_library_names(&mut self) -> Vec<String> {
        serde_json::from_str::<Value>(&brep_kernel::parts_library_json())
            .ok()
            .and_then(|value| {
                value
                    .as_object()
                    .map(|map| map.keys().cloned().collect::<Vec<_>>())
            })
            .unwrap_or_default()
    }

    // --- the adopt + fold (pose-authority contract) -------------------------

    /// Adopt the assembly tail the runner shipped with the applied run: the
    /// component projection, and the post-solve constraint state folded back
    /// into the document (silent — a solver write-back is not a user edit; the
    /// pose/`isFixed` half was already folded in `apply_run_output`, and the
    /// parts-library store the run moved was installed there too).
    /// Componentless documents just clear the projection.
    ///
    /// This is the call that used to be `sync_assembly`, a second full
    /// `execute_history` on the UI thread. See `pipeline::AssemblySync`.
    pub(crate) fn adopt_assembly_sync(&mut self) {
        let Some(sync) = &self.assembly_sync else {
            self.assembly_components.clear();
            // No ACOMP references left ⇒ any partsLibrary block is orphaned
            // payload (the kernel GCs the store the same way at the end of
            // every run) — drop it so deleting the last instance never leaves
            // a dangling entry in the saved document. Gated on the WHOLE
            // history, not on the run: the run executes the rolled-to PREFIX,
            // so rolling back behind the first ACOMP ships no tail while the
            // document's components — and therefore its library — are still
            // very much there.
            if !self.history_has_assembly() {
                self.history
                    .set_parts_library(Value::Object(serde_json::Map::new()));
            }
            return;
        };
        self.assembly_components = sync.components.clone();
        let state = sync.state.clone();
        // Legacy Fixed rows this run turned into the instance setting (the
        // kernel lifecycle's migration): say so once — the fold below drops
        // them from the document, so the next run carries none.
        let migrated: Vec<String> = sync.dof["migratedFixed"]
            .as_array()
            .into_iter()
            .flatten()
            .map(|row| {
                let id = row["id"].as_str().unwrap_or("Fixed");
                match row["component"].as_str() {
                    Some(component) => format!(
                        "Fixed constraint {id} is now {component}'s Fixed setting: a part is fixed on its own row, not by a constraint"
                    ),
                    None => format!(
                        "Fixed constraint {id} was removed: it fixed nothing ({}); fix a part on its own row",
                        if row["enabled"] == false { "it was disabled".to_string() } else { format!("'{}' names no component", row["element"].as_str().unwrap_or("")) }
                    ),
                }
            })
            .collect();
        for notice in migrated {
            self.push_notice(notice);
        }
        self.fold_assembly_state(state, false);
    }

    /// Write the post-solve `assembly` block into the document — the state half
    /// of the pose-authority write-back, which the kernel's
    /// `assembly_apply_document_json` performs alongside the pose half.
    /// `checkpoint` = true for a USER mutation (undoable), false for the silent
    /// post-run adopt. A null state (no session / no constraints yet) writes
    /// nothing.
    fn fold_assembly_state(&mut self, state: Value, checkpoint: bool) {
        if state.is_null() {
            return;
        }
        if self.history.assembly_block() == Some(&state) {
            return;
        }
        self.history.set_assembly_block(state, checkpoint);
    }

    /// Make sure a main-side kernel assembly SESSION exists for the last applied
    /// run — the one thing the runner's reply cannot carry.
    ///
    /// A constraint mutation auto-solves immediately, against the resident
    /// geometry of whichever thread holds it, and the kernel's registry and
    /// session are both thread-local; so a mutation needs the history executed
    /// HERE. That is per USER constraint edit — adding, editing, enabling,
    /// reordering or removing a mate, inferring constraints, or pressing Solve —
    /// not per run, which is the whole difference from the old `sync_assembly`.
    /// Cheap no-op when the session is already current, and for a componentless
    /// document (nothing to solve).
    ///
    /// The runner's own session comes first: an owned copy of it
    /// ([`crate::runner::HistoryRunner::session_for_main`]), imported when it is
    /// the session of the run applied here, and only while no newer run is in
    /// flight. Otherwise, and on any refusal, the history is replayed here as
    /// before.
    pub(crate) fn ensure_assembly_session(&mut self) {
        if self.assembly_session_generation == Some(self.applied_generation)
            && self.assembly_session_serial == brep_kernel::assembly_session_serial()
        {
            return;
        }
        if !self.history_has_assembly() {
            return;
        }
        if self.session_transfer && !self.run_pending() {
            let mut _trace = crate::run_trace::span("assembly_session_import");
            match self.runner.session_for_main(self.applied_generation) {
                crate::runner::SessionOnMain::Resident => {
                    self.session_imports += 1;
                    self.mark_session_current();
                    return;
                }
                crate::runner::SessionOnMain::Copied(transfer) => {
                    let retired = brep_kernel::assembly_session_import(transfer);
                    // Dropping the replaced copy costs milliseconds: not here.
                    #[cfg(not(target_arch = "wasm32"))]
                    std::thread::spawn(move || drop(retired));
                    #[cfg(target_arch = "wasm32")]
                    drop(retired);
                    self.session_imports += 1;
                    self.mark_session_current();
                    return;
                }
                crate::runner::SessionOnMain::Unavailable => {}
            }
        }
        self.session_replays += 1;
        let request: brep_kernel::HistoryRequest =
            match serde_json::from_value(self.run_request_value()) {
                Ok(request) => request,
                Err(_) => return, // unparseable mid-edit document — retry next frame
            };
        let mut _trace = crate::run_trace::span("assembly_session");
        let result = self.execute_plugin_history(&request);
        if let Some(trace) = _trace.as_mut() {
            trace.result(&result);
        }
        self.mark_session_current();
    }

    fn mark_session_current(&mut self) {
        self.assembly_session_generation = Some(self.applied_generation);
        self.assembly_session_serial = brep_kernel::assembly_session_serial();
    }

    /// Drop the main-side session's currency stamp, so the next
    /// `Self::ensure_assembly_session` replays this document. The kernel
    /// session is one per thread, shared by every open document: a caller that
    /// may have let another document replay (or changed the kernel's parts
    /// library) since this one's last replay calls this first.
    pub fn forget_assembly_session(&mut self) {
        self.assembly_session_generation = None;
    }

    /// Re-read the main-side session into [`Self::assembly_sync`] after a
    /// MUTATION solved against it, so the panels (which read the snapshot, not
    /// the kernel) show the mutation immediately instead of lagging until the
    /// rerun's reply lands. The component projection is unchanged by a
    /// constraint mutation — only the solve moves poses, and the rerun brings
    /// those — so it is carried over.
    pub(crate) fn snapshot_assembly_from_session(&mut self) {
        let parse = |json: String| serde_json::from_str::<Value>(&json).unwrap_or(Value::Null);
        let components = self.assembly_components.clone();
        self.assembly_sync = Some(crate::pipeline::AssemblySync {
            components,
            state: parse(brep_kernel::assembly_state_json()),
            statuses: parse(brep_kernel::assembly_statuses_json()),
            dof: parse(brep_kernel::assembly_dof_json()),
            overlay: parse(brep_kernel::assembly_overlay_json()),
            parts_library: None,
        });
    }

    /// Run the document fold: `assembly_apply_document_json(document)` →
    /// adopt the returned document (assembly block replaced; solved poses +
    /// isFixed folded into features by `inputParams.id`). `checkpoint` = true
    /// for USER mutations (undoable), false for the silent post-run fold.
    /// A missing session (no run yet) is tolerated silently.
    fn apply_assembly_fold(&mut self, checkpoint: bool) {
        // The fold only rewrites the `assembly` block and per-feature
        // `inputParams` — it never reads `partsLibrary` — so hand it the
        // document WITHOUT the library. Otherwise every edit serialized,
        // parsed and re-serialized the whole embedded part payload three times
        // over for nothing. `adopt_document` keeps the field when the adopted
        // document carries no block, so the library survives the round trip.
        let document = self.history.request_json_without_parts_library();
        match brep_kernel::assembly_apply_document_impl(&document) {
            Ok(folded) => {
                let adopted = if checkpoint {
                    self.history.adopt_document_checkpointed(&folded)
                } else {
                    self.history.adopt_document(&folded)
                };
                if let Err(error) = adopted {
                    self.push_notice(format!("assembly fold failed: {error}"));
                }
                // The panels read the SHIPPED snapshot, not the kernel, so a
                // mutation that solved against the live session has to refresh
                // it — otherwise the constraint the user just added shows its
                // pre-solve status until the rerun's reply lands a frame or more
                // later. ONLY on the Ok arm: with no session the kernel's reads
                // answer with defaults, which would blank a good snapshot.
                self.snapshot_assembly_from_session();
            }
            Err(_) => {} // no session yet (fresh document before its first run)
        }
    }

    /// Shared tail of every USER constraint mutation: fold (checkpointed) and,
    /// when auto-solve is on, re-run the display history so the viewport
    /// re-poses (changed ACOMP transforms dirty their fingerprints → those
    /// instances re-execute + re-tessellate; the runner's tail re-solves).
    ///
    /// `pub(super)` because a constraint mutation also arrives from the VIEWPORT:
    /// an overlay handle drag commits on release
    /// ([`EngineState::constraint_drag_release`]) and needs the same two things
    /// this gives every panel edit — an undo entry for the drag, and a rerun
    /// that re-keys the moved instance's cache entry to its new pose.
    pub(super) fn after_constraint_mutation(&mut self) {
        self.apply_assembly_fold(true);
        if self.settings.assembly_auto_solve {
            self.rerun_history();
        } else {
            self.dirty = true;
        }
    }

    // --- constraint mutations (kernel ABI + fold + optional rerun) ----------

    /// Add a constraint (`params_json` = inputParams; the kernel mints the id
    /// from the type's short name when absent). Returns the minted id.
    pub fn assembly_add_constraint(
        &mut self,
        constraint_type: &str,
        params_json: &str,
    ) -> Result<String, String> {
        if constraint_type == "fixed" {
            return Err(brep_kernel::FIXED_IS_NOT_A_CONSTRAINT.to_string());
        }
        self.ensure_assembly_session();
        let reply =
            brep_kernel::assembly_add_constraint_impl(constraint_type, params_json)?;
        self.after_constraint_mutation();
        let id = serde_json::from_str::<Value>(&reply)
            .ok()
            .and_then(|value| value.get("id").and_then(|id| id.as_str()).map(String::from))
            .unwrap_or_default();
        Ok(id)
    }

    /// Replace a constraint's `inputParams` (the dialog commit).
    pub fn assembly_update_constraint(&mut self, id: &str, params_json: &str) -> Result<(), String> {
        self.ensure_assembly_session();
        brep_kernel::assembly_update_constraint_impl(id, params_json)?;
        self.after_constraint_mutation();
        Ok(())
    }

    /// Update WITHOUT the rerun tail — for callers whose own continuation
    /// re-runs anyway (the ref-select Finish, whose `end_ref_select` reruns).
    /// Still folds (checkpointed) so the document is current before that run.
    pub(crate) fn assembly_update_constraint_no_rerun(
        &mut self,
        id: &str,
        params_json: &str,
    ) -> Result<(), String> {
        self.ensure_assembly_session();
        brep_kernel::assembly_update_constraint_impl(id, params_json)?;
        self.apply_assembly_fold(true);
        Ok(())
    }

    /// Delete a constraint.
    pub fn assembly_remove_constraint(&mut self, id: &str) -> Result<(), String> {
        self.ensure_assembly_session();
        brep_kernel::assembly_remove_constraint_impl(id)?;
        self.after_constraint_mutation();
        Ok(())
    }

    /// Enable/disable a constraint (the row checkbox).
    pub fn assembly_set_constraint_enabled(&mut self, id: &str, enabled: bool) -> Result<(), String> {
        self.ensure_assembly_session();
        brep_kernel::assembly_set_constraint_enabled_impl(id, enabled)?;
        self.after_constraint_mutation();
        Ok(())
    }

    /// Persist WHICH constraint has its dialog open (view state — SILENT fold,
    /// no solve, no rerun, no undo entry). An open COUNTS
    /// ([`Self::assembly_constraint_opens`]) — including a re-open of the
    /// constraint already open, which is what the dialog door needs to surface
    /// the pane for a label click on a form the user cannot see. ACCORDION: at
    /// most ONE constraint is open — opening one first closes every other inside the same silent fold,
    /// so the panel toggle, the context bar's add-from-selection, and a viewport
    /// label click all converge on a single open constraint. The constraints
    /// PANEL reads this flag as its mode: open one and its form replaces the
    /// tree (`panels::assembly_constraints`), which is why every one of those
    /// surfaces opens the dialog without knowing the panel exists.
    pub fn assembly_set_constraint_open(&mut self, id: &str, open: bool) -> Result<(), String> {
        self.ensure_assembly_session();
        if open {
            let others: Vec<String> =
                serde_json::from_str::<Value>(&brep_kernel::assembly_state_json())
                    .ok()
                    .and_then(|state| {
                        state.get("constraints").and_then(|l| l.as_array()).map(|entries| {
                            entries
                                .iter()
                                .filter_map(|entry| {
                                    let cid =
                                        entry.get("inputParams")?.get("id")?.as_str()?;
                                    let is_open =
                                        entry.get("open").and_then(|v| v.as_bool()).unwrap_or(false);
                                    (is_open && cid != id).then(|| cid.to_string())
                                })
                                .collect()
                        })
                    })
                    .unwrap_or_default();
            for other in others {
                brep_kernel::assembly_set_constraint_open_impl(&other, false)?;
            }
        }
        brep_kernel::assembly_set_constraint_open_impl(id, open)?;
        if open {
            self.constraint_opens = self.constraint_opens.wrapping_add(1);
        }
        self.apply_assembly_fold(false);
        Ok(())
    }

    /// How many constraint dialogs this engine has OPENED through
    /// [`Self::assembly_set_constraint_open`] — the app's dialog door reads it
    /// beside [`Self::assembly_open_constraint`] so a re-open of the constraint
    /// already open is still an open (the id alone does not move: a viewport
    /// label click on the constraint whose form is up, made precisely because
    /// the user is on another tab).
    ///
    /// It counts the OPENS, not the flag's history. The flag itself lives in
    /// the kernel and a freshly minted constraint arrives `open: true` without
    /// passing through the setter — that one needs no count, because a new
    /// constraint brings a new id and the door's id half already sees it.
    pub fn assembly_constraint_opens(&self) -> u64 {
        self.constraint_opens
    }

    /// The id of the constraint whose DIALOG is open, or `None`.
    ///
    /// The accordion keeps at most one (`assembly_set_constraint_open` closes
    /// every other), so this is the panel's mode as ONE value. Reads the same
    /// two fields the Constraints panel's row builder reads — `inputParams.id`
    /// and `open` — off the borrowed fold state, without cloning the whole
    /// assembly block the way `assembly_state_value` does: the app's dialog
    /// door calls this every frame.
    pub fn assembly_open_constraint(&self) -> Option<String> {
        self.assembly_sync()
            .state
            .get("constraints")?
            .as_array()?
            .iter()
            .find(|entry| entry.get("open").and_then(Value::as_bool).unwrap_or(false))
            .and_then(|entry| entry.get("inputParams")?.get("id")?.as_str())
            .map(str::to_string)
    }

    /// Reorder a constraint to `index` (drag-reorder).
    pub fn assembly_move_constraint(&mut self, id: &str, index: usize) -> Result<(), String> {
        self.ensure_assembly_session();
        brep_kernel::assembly_move_constraint_impl(id, index)?;
        self.after_constraint_mutation();
        Ok(())
    }

    // --- automatic inference (the Auto Constraints button) ------------------

    /// The constraint types the inference lane can produce — `[{type, label,
    /// icon, longName, detects, defaultOn}]`, straight off the kernel's rule
    /// table. The dialog lists THIS; it keeps no roster of its own, so a rule
    /// added in the kernel shows up as a row without an app change.
    pub fn assembly_inferable_types(&self) -> Value {
        serde_json::from_str(&brep_kernel::assembly_inferable_types_json())
            .unwrap_or_else(|_| Value::Array(Vec::new()))
    }

    /// SCAN the placed components for the constraints their placement implies,
    /// creating nothing. `options` is the kernel's `InferOptions` JSON (`{}` for
    /// the defaults; the dialog sends the ticked `types`).
    pub fn assembly_infer_constraints(&mut self, options: &str) -> Value {
        self.ensure_assembly_session();
        serde_json::from_str(&brep_kernel::assembly_infer_constraints_json(options))
            .unwrap_or(Value::Null)
    }

    /// Scan and CREATE, as one undoable step: the kernel adds every accepted
    /// candidate and solves the batch once, then the usual mutation tail folds
    /// the result into the document and re-runs the display. A scan that found
    /// nothing skips the tail entirely (no checkpoint, no rerun).
    pub fn assembly_apply_inferred_constraints(&mut self, options: &str) -> Value {
        self.ensure_assembly_session();
        let reply: Value =
            serde_json::from_str(&brep_kernel::assembly_apply_inferred_constraints_json(options))
                .unwrap_or(Value::Null);
        let created = reply
            .get("created")
            .and_then(|value| value.as_array())
            .map(|list| list.len())
            .unwrap_or(0);
        if created > 0 {
            self.after_constraint_mutation();
        }
        reply
    }

    /// Manual solve (the panel's Solve button): solve the session, fold, and
    /// ALWAYS re-run the display (that is the point of pressing Solve — it
    /// works with auto-solve disabled).
    pub fn assembly_run_solve(&mut self) -> Result<(), String> {
        self.ensure_assembly_session();
        // Nothing to solve without components — bail BEFORE the kernel solve:
        // an empty assembly solving to a no-op is the correct result. (This
        // bail was once also the only thing between a missing session and a
        // native abort; the `_impl` doors now return that error as text.)
        if self.assembly_components().is_empty() {
            return Ok(());
        }
        brep_kernel::assembly_run_solve_impl()?;
        self.apply_assembly_fold(true);
        self.rerun_history();
        Ok(())
    }

    // --- component actions (route to the owning ACOMP feature) --------------

    /// Insert a component instance (the palette/insert flow): resolve the
    /// parts-library entry (add or reuse), refresh the document's
    /// `partsLibrary` block, append an ACOMP feature referencing the RETURNED
    /// effective part name with an identity transform, and re-run. The FIRST
    /// component of the document writes `isFixed: true` EXPLICITLY (dialog-
    /// visible); later instances write `false`. Returns the new feature id
    /// (`ACOMP<digits>` — the history's global counter mints that exact form).
    pub fn insert_component(&mut self, insert: ComponentInsert<'_>) -> Result<String, String> {
        let part_name = self.resolve_component_insert(insert)?;
        let first = !(0..self.history.len()).any(|index| {
            matches!(
                self.history.feature_type(index).as_deref(),
                Some("ACOMP") | Some("ASSEMBLY COMPONENT")
            )
        });
        let id = self.history.next_feature_id("ACOMP");
        let feature = serde_json::json!({
            "type": "ACOMP",
            "inputParams": {
                "id": id,
                "partName": part_name,
                "transform": { "translate": [0, 0, 0], "rotateEulerDeg": [0, 0, 0] },
                "isFixed": first,
            },
            "persistentData": {}
        });
        self.add_feature(&feature.to_string())?;
        Ok(id)
    }

    /// The parts-library entry an insert (or a replace) names: an existing
    /// entry as is, or a new payload added (deduplicated by
    /// sourceKey+signature) — with the document's `partsLibrary` block
    /// refreshed so the next run ingests it. Returns the EFFECTIVE entry name.
    pub(crate) fn resolve_component_insert(&mut self, insert: ComponentInsert<'_>) -> Result<String, String> {
        let part_name = match insert {
            ComponentInsert::Existing { part_name } => {
                if !self.parts_library_names().iter().any(|name| name == part_name) {
                    return Err(format!("part '{part_name}' is not in the parts library"));
                }
                part_name.to_string()
            },
            ComponentInsert::New {
                name,
                source_key,
                source_signature,
                document_json,
            } => match brep_kernel::add_part_to_library_impl(
                name,
                source_key,
                source_signature,
                document_json,
            ) {
                Ok(part_name) => part_name,
                Err(error) => {
                    // The Insert component dialog closes on Insert, so its
                    // status line never shows this; the toast is what the user
                    // sees (a file that is not a part document, for one).
                    self.push_notice(format!("Could not insert '{name}': {error}"));
                    return Err(error);
                }
            },
        };
        // The block must ride the request so the display runner ingests the
        // (new) entry on the very next run.
        if let Ok(library) = serde_json::from_str::<Value>(&brep_kernel::parts_library_json()) {
            self.history.set_parts_library(library);
        }
        Ok(part_name)
    }

    /// Fix/Unfix a component: toggles the OWNING ACOMP feature's
    /// `inputParams.isFixed` (one truth, one undo lane) and re-runs. A nested
    /// occurrence (`ACOMP1:ACOMP2`) is fixed or freed on the top instance that
    /// lends it (`inputParams.flexibleFixed`, by instance-local path), never in
    /// the sub-assembly's document; [`nested_fixed_state`] says when that is
    /// not possible.
    pub fn set_component_fixed(&mut self, component_id: &str, fixed: bool) -> Result<(), String> {
        if let Some((top, rest)) = component_id.split_once(':') {
            self.occurrence_fixed(component_id)?;
            let index = self.history.index_of(top).ok_or_else(|| format!("no component feature '{top}'"))?;
            let mut params = self.history.feature_params(index).unwrap_or_else(|| serde_json::json!({}));
            let Some(map) = params.as_object_mut() else {
                return Err(format!("component '{top}': malformed inputParams"));
            };
            let overrides = map.entry("flexibleFixed").or_insert_with(|| serde_json::json!({}));
            if !overrides.is_object() {
                *overrides = serde_json::json!({});
            }
            overrides[rest] = Value::Bool(fixed);
            self.update_feature_params(top, &params.to_string())?;
            return Ok(());
        }
        self.set_components_fixed(&[component_id.to_string()], fixed)
    }

    /// Fix/Unfix several top-level components at once (a packed BOM row's
    /// placements): every owning ACOMP's `inputParams.isFixed`, ONE undo step,
    /// one re-run. The same write [`Self::set_component_fixed`] makes for one.
    pub fn set_components_fixed(&mut self, component_ids: &[String], fixed: bool) -> Result<(), String> {
        let mut edits = Vec::with_capacity(component_ids.len());
        for component_id in component_ids {
            if component_id.contains(':') {
                return Err(format!("'{component_id}' is a nested occurrence: fix or unfix it on its own"));
            }
            let index = self
                .history
                .index_of(component_id)
                .ok_or_else(|| format!("no component feature '{component_id}'"))?;
            let mut params = self
                .history
                .feature_params(index)
                .unwrap_or_else(|| serde_json::json!({}));
            let Some(map) = params.as_object_mut() else {
                return Err(format!("component '{component_id}': malformed inputParams"));
            };
            map.insert("isFixed".into(), Value::Bool(fixed));
            edits.push((component_id.clone(), params));
        }
        self.update_many_feature_params(&edits)?;
        Ok(())
    }

    /// Whether a nested occurrence (`ACOMP1:ACOMP2`) is fixed, or why it
    /// cannot be fixed or freed from this assembly ([`nested_fixed_state`]).
    pub fn occurrence_fixed(&self, path: &str) -> Result<bool, String> {
        let (top, rest) = path.split_once(':').ok_or_else(|| format!("'{path}' is not a nested occurrence"))?;
        let index = self.history.index_of(top).ok_or_else(|| format!("no component feature '{top}'"))?;
        let params = self.history.feature_params(index).unwrap_or_default();
        let part = params["partName"].as_str().unwrap_or_default();
        nested_fixed_state(&params, rest, &self.history.parts_library()[part]["document"])
    }

    /// Select a component in the viewport: emphasis over exactly its member
    /// solids (the tree↔viewport sync lane; a viewport pick of any member
    /// lights the tree row through the same emphasis set).
    pub fn select_component(&mut self, component_id: &str) {
        self.select_components(&[component_id.to_string()]);
    }

    /// A component's member SOLID scene names (empty for an unknown id) — the
    /// selection/hover unit COMPONENT entries resolve to.
    pub fn component_member_solids(&self, component_id: &str) -> Vec<String> {
        self.assembly_components
            .iter()
            .filter(|record| record.id == component_id)
            .flat_map(|record| record.solids.iter().cloned())
            .collect()
    }

    /// TOGGLE a component in the current selection as ONE unit (the additive
    /// Ctrl/Cmd+click under COMPONENT promotion): when EVERY member solid is
    /// already selected the whole set deselects, otherwise the whole set joins
    /// the selection — the rest of the selection stays.
    pub fn toggle_component_selection(&mut self, component_id: &str) {
        let members = self.component_member_solids(component_id);
        if members.is_empty() {
            return;
        }
        let all_selected = members
            .iter()
            .all(|name| self.emphasis.selected_solids.contains(name));
        for name in members {
            if all_selected {
                self.emphasis.selected_solids.remove(&name);
            } else {
                self.emphasis.selected_solids.insert(name);
            }
        }
        self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
        self.dirty = true;
    }

    /// Select SEVERAL components at once: emphasis over the union of their
    /// member solids (the interference window's row click highlights both
    /// participants of a pair through this).
    pub fn select_components(&mut self, component_ids: &[String]) {
        // An id may be a nested occurrence (`ACOMP2:ACOMP1`): the solids of
        // its top component under that path.
        let members: Vec<String> = self
            .assembly_components
            .iter()
            .flat_map(|record| {
                record.solids.iter().filter(move |solid| {
                    component_ids.iter().any(|id| {
                        id == &record.id
                            || (id.split(':').next() == Some(record.id.as_str()) && solid.starts_with(&format!("{id}:")))
                    })
                })
            })
            .cloned()
            .collect();
        let json = serde_json::json!({ "selected": { "solids": members } }).to_string();
        let _ = self.emphasis.apply_json(&json);
        self.dirty = true;
    }

    // --- constraint reference selection (the ref-select reuse) --------------

    /// Enter the modal reference picker for an ASSEMBLY CONSTRAINT's
    /// `elements`-style field (same widget, different commit target — Finish
    /// routes through `Self::assembly_update_constraint_no_rerun` instead of
    /// feature params). No roll-to-before: constraints pick against the FULL
    /// assembly.
    pub fn begin_ref_select_for_constraint(
        &mut self,
        constraint_id: &str,
        path: Vec<String>,
        label: String,
        filter: Vec<String>,
        multiple: bool,
        seed_names: Vec<String>,
    ) {
        let restore_index = self.history.rollback();
        self.selection_filter = SelectionFilter::from_ref_filter(&filter);
        self.ref_select = Some(RefSelectState {
            feature_id: constraint_id.to_string(),
            path,
            label,
            filter,
            multiple,
            names: seed_names,
            restore_index,
            target: RefSelectTarget::AssemblyConstraint,
            picks: 0,
            claimed: false,
            finish_requested: false,
        });
        self.sync_ref_select_emphasis();
    }

    /// Commit a finished constraint ref-select: read the constraint's params
    /// from the session state, write the picked names at the field path, and
    /// update (fold only — the caller's continuation reruns).
    pub(crate) fn assembly_commit_constraint_refs(
        &mut self,
        constraint_id: &str,
        path: &[String],
        names: &[String],
        multiple: bool,
    ) {
        let state = self.assembly_state_value();
        let Some(entry) = state
            .get("constraints")
            .and_then(Value::as_array)
            .and_then(|constraints| {
                constraints.iter().find(|entry| {
                    entry
                        .get("inputParams")
                        .and_then(|params| params.get("id"))
                        .and_then(Value::as_str)
                        == Some(constraint_id)
                })
            })
        else {
            self.push_notice(format!("unknown constraint '{constraint_id}'"));
            return;
        };
        let mut params = entry
            .get("inputParams")
            .cloned()
            .unwrap_or_else(|| serde_json::json!({}));
        let value = if multiple {
            Value::Array(names.iter().cloned().map(Value::String).collect())
        } else {
            Value::String(names.first().cloned().unwrap_or_default())
        };
        super::selection_ux::set_json_at(&mut params, path, value);
        if let Err(error) = self.assembly_update_constraint_no_rerun(constraint_id, &params.to_string())
        {
            self.push_notice(format!("constraint update failed: {error}"));
        }
    }

    /// Build the `{solidName}@x,y,z` COMPONENT-LOCAL vertex ref for a vertex
    /// pick on a component member (lane-E contract: world pick transformed by
    /// the owning component's inverse pose). `None` when the solid belongs to
    /// no component (vertex refs only exist for constraint selection).
    pub(crate) fn component_vertex_ref(
        &self,
        solid_name: &str,
        world_position: [f64; 3],
    ) -> Option<String> {
        let record = self.assembly_components.iter().find(|record| {
            record.solids.iter().any(|member| member == solid_name)
        })?;
        let local = rigid_inverse_point(&record.affine(), world_position);
        Some(format!(
            "{solid_name}@{},{},{}",
            local[0], local[1], local[2]
        ))
    }
}