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

// ===========================================================================
// Assembly surface (Wave-3 lane F) — the engine-state altitude the app's
// assembly panels program against.
//
// # Where the kernel assembly session LIVES (the main-side sync pattern)
//
// The kernel's assembly session (constraint state + scene clone, installed by
// the `execute_history` tail) is THREAD-LOCAL to whichever thread ran the
// history. The DISPLAY run happens on the runner (native thread / wasm
// worker), so the main thread's session would be cold. Following the repo's
// established pattern for main-side kernel-state needs (`export_step_text`,
// `flat_pattern_target_handle`, `consumed_feature_names` all re-execute the
// history main-side against the warm incremental cache), [`sync_assembly`]
// runs `brep_kernel::execute_history` ON THE MAIN THREAD after every applied
// display run — a cache-hit replay for unchanged features — which installs a
// fresh main-side session, surfaces the scene's ComponentRecords, and folds
// the solved state back into the document (the pose-authority contract).
// Componentless documents skip ALL of it (`history_has_assembly` gates).
//
// SEAM (integrator): a runner-protocol extension could move the assembly ABI
// off-thread later; until then the first sync after opening a heavy assembly
// re-executes cold on the UI thread (wasm: a one-time stall).
//
// # The per-mutation lifecycle (spec §6)
//
// Every constraint mutation: (1) ensure the main-side session is current,
// (2) call the kernel mutation ABI (which auto-solves the session), (3) FOLD
// the session back into the document (`assembly_apply_document_json` → adopt,
// checkpointed = undoable), (4) when `settings.assembly_auto_solve` is on,
// re-run the history through the display runner so the viewport re-poses.
// With auto-solve off, step 4 waits for the manual Solve button.
// ===========================================================================

/// 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.
/// (Distinct from the kernel's `parts_library::stable_json_hash`, which is an
/// in-memory `doc_hash` for the per-feature fingerprint hook.)
pub fn document_signature(doc_json: &str) -> String {
    use std::hash::{Hash, Hasher};
    fn walk(value: &serde_json::Value, hasher: &mut impl Hasher) {
        match value {
            serde_json::Value::Null => 0u8.hash(hasher),
            serde_json::Value::Bool(flag) => {
                1u8.hash(hasher);
                flag.hash(hasher);
            }
            serde_json::Value::Number(number) => {
                2u8.hash(hasher);
                number.to_string().hash(hasher);
            }
            serde_json::Value::String(text) => {
                3u8.hash(hasher);
                text.hash(hasher);
            }
            serde_json::Value::Array(items) => {
                4u8.hash(hasher);
                for item in items {
                    walk(item, hasher);
                }
            }
            serde_json::Value::Object(map) => {
                5u8.hash(hasher);
                let mut keys: Vec<&String> = map.keys().collect();
                keys.sort();
                for key in keys {
                    key.hash(hasher);
                    walk(&map[key], hasher);
                }
            }
        }
    }
    let mut hasher = std::collections::hash_map::DefaultHasher::new();
    match serde_json::from_str::<serde_json::Value>(doc_json) {
        Ok(value) => walk(&value, &mut hasher),
        Err(_) => doc_json.hash(&mut hasher),
    }
    format!("{:016x}", hasher.finish())
}

/// Stringify a kernel ABI error. The kernel exports return `JsValue` errors
/// (wasm-bindgen, built from plain strings); this crate does not depend on
/// wasm-bindgen directly, so stringify generically via `Debug` — for a
/// string-payload `JsValue` that is the message (quoted), which is all the
/// notice/status lanes need.
fn js_err<E: std::fmt::Debug>(error: E) -> String {
    format!("{error:?}")
}

/// 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
    /// [`sync_assembly`] — the Assembly Structure tree's projection source.
    /// A VIEW over the scene, never an owning structure.
    pub fn assembly_components(&self) -> &[brep_kernel::ComponentRecord] {
        &self.assembly_components
    }

    /// Make sure the main-side assembly session + component projection are
    /// current with the last APPLIED display run. Cheap no-op when already
    /// synced (or for componentless documents). Panels call this at frame
    /// start; the post-run tail (`finish_apply`) also syncs eagerly.
    pub fn ensure_assembly_synced(&mut self) {
        if self.assembly_synced_generation == Some(self.applied_generation) {
            return;
        }
        self.sync_assembly();
    }

    /// The per-constraint status rows (`[{id, type, enabled, open, status,
    /// message, satisfied, error}]`) from the main-side session.
    pub fn assembly_statuses_value(&mut self) -> Value {
        self.ensure_assembly_synced();
        serde_json::from_str(&brep_kernel::assembly_statuses_json())
            .unwrap_or_else(|_| Value::Array(Vec::new()))
    }

    /// The current `assembly` block (post-solve): `{constraints, idCounter}`.
    pub fn assembly_state_value(&mut self) -> Value {
        self.ensure_assembly_synced();
        serde_json::from_str(&brep_kernel::assembly_state_json()).unwrap_or(Value::Null)
    }

    /// 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.ensure_assembly_synced();
        serde_json::from_str(&brep_kernel::assembly_dof_json()).unwrap_or(Value::Null)
    }

    /// 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 {
        self.ensure_assembly_synced();
        serde_json::from_str(&brep_kernel::assembly_overlay_json())
            .unwrap_or_else(|_| Value::Array(Vec::new()))
    }

    /// The parts-library entry names currently resident (insert-flow "existing
    /// entries first" list). Main-side store — seeded by document ingest on
    /// sync and grown by [`insert_component`].
    pub fn parts_library_names(&mut self) -> Vec<String> {
        if self.history_has_assembly() {
            self.ensure_assembly_synced();
        }
        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 sync + fold (pose-authority contract) --------------------------

    /// Re-execute the rolled-to history MAIN-SIDE (warm-cache replay), fold
    /// the resulting ComponentRecords into [`Self::assembly_components`], fold
    /// the post-solve session state + poses back into the document (silent
    /// adopt — solver write-back is not a user edit), and refresh the
    /// document's `partsLibrary` block from the kernel store (post-GC; SAVE
    /// must serialize the store, never echo a loaded block). Componentless
    /// documents just clear the projection.
    pub(crate) fn sync_assembly(&mut self) {
        if !self.history_has_assembly() {
            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.
            self.history
                .set_parts_library(Value::Object(serde_json::Map::new()));
            self.assembly_synced_generation = Some(self.applied_generation);
            return;
        }
        let request: brep_kernel::HistoryRequest =
            match serde_json::from_value(self.history.prefix_request()) {
                Ok(request) => request,
                Err(_) => return, // unparseable mid-edit document — retry next frame
            };
        let result = brep_kernel::execute_history(&request);

        // Fold the per-feature component side-channel exactly like
        // SceneMap::apply: removals unmap by id, additions insert in order
        // (BTreeMap ⇒ deterministic id order for the tree/BOM).
        let mut components: std::collections::BTreeMap<String, brep_kernel::ComponentRecord> =
            std::collections::BTreeMap::new();
        for feature in &result.results {
            for removed in &feature.removed {
                components.remove(removed);
            }
            for record in &feature.components {
                components.insert(record.id.clone(), record.clone());
            }
        }
        self.assembly_components = components.into_values().collect();

        // Fold the session (solved constraint state + poses/isFixed) into the
        // document — the pose-authority write-back, silent (not a user edit).
        self.apply_assembly_fold(false);

        // SAVE-side contract: the document's partsLibrary block mirrors the
        // kernel store (heals + GC included). Fingerprint-neutral: snapshots
        // are excluded from the ACOMP content hash.
        // Read the revision AFTER the run above: its ACOMP self-heal and orphan
        // GC are library mutations, and they bump it. Serializing the store
        // costs the whole embedded part payload, so do it only when the library
        // actually moved — in the steady state (edit, roll, re-solve) it does
        // not, and this is a single integer compare.
        let revision = brep_kernel::parts_library_revision();
        if self.parts_library_block_revision != Some(revision)
            || !self.history.parts_library_mirrors_store()
        {
            if let Ok(library) = serde_json::from_str::<Value>(&brep_kernel::parts_library_json()) {
                self.history.set_parts_library(library);
                self.parts_library_block_revision = Some(revision);
            }
        }
        self.assembly_synced_generation = Some(self.applied_generation);
    }

    /// 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_json(&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}"));
                }
            }
            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).
    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> {
        self.ensure_assembly_synced();
        let reply =
            brep_kernel::assembly_add_constraint_json(constraint_type, params_json).map_err(js_err)?;
        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_synced();
        brep_kernel::assembly_update_constraint_json(id, params_json).map_err(js_err)?;
        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_synced();
        brep_kernel::assembly_update_constraint_json(id, params_json).map_err(js_err)?;
        self.apply_assembly_fold(true);
        Ok(())
    }

    /// Delete a constraint.
    pub fn assembly_remove_constraint(&mut self, id: &str) -> Result<(), String> {
        self.ensure_assembly_synced();
        brep_kernel::assembly_remove_constraint_json(id).map_err(js_err)?;
        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_synced();
        brep_kernel::assembly_set_constraint_enabled_json(id, enabled).map_err(js_err)?;
        self.after_constraint_mutation();
        Ok(())
    }

    /// Persist WHICH constraint has its dialog open (view state — SILENT fold,
    /// no solve, no rerun, no undo entry). 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_synced();
        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_json(&other, false).map_err(js_err)?;
            }
        }
        brep_kernel::assembly_set_constraint_open_json(id, open).map_err(js_err)?;
        self.apply_assembly_fold(false);
        Ok(())
    }

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

    /// 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_synced();
        // Nothing to solve without components — bail BEFORE the kernel solve.
        // With no assembly session the kernel's solve/fold error paths build a
        // `JsValue`, and wasm-bindgen's `JsValue` is unimplemented on native
        // targets: the construction panics ("function not implemented on
        // non-wasm32 targets") in a nounwind context and ABORTS the desktop
        // app. An empty assembly solving to a no-op is also the correct result.
        if self.assembly_components().is_empty() {
            return Ok(());
        }
        brep_kernel::assembly_run_solve_json().map_err(js_err)?;
        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 = match insert {
            ComponentInsert::Existing { part_name } => part_name.to_string(),
            ComponentInsert::New {
                name,
                source_key,
                source_signature,
                document_json,
            } => brep_kernel::add_part_to_library(name, source_key, source_signature, document_json)
                .map_err(js_err)?,
        };
        // 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);
        }
        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)
    }

    /// Fix/Unfix a component: toggles the OWNING ACOMP feature's
    /// `inputParams.isFixed` (one truth, one undo lane) and re-runs.
    pub fn set_component_fixed(&mut self, component_id: &str, fixed: bool) -> Result<(), String> {
        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!({}));
        if let Some(map) = params.as_object_mut() {
            map.insert("isFixed".into(), Value::Bool(fixed));
        } else {
            return Err(format!("component '{component_id}': malformed inputParams"));
        }
        self.update_feature_params(component_id, &params.to_string())?;
        Ok(())
    }

    /// 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]) {
        let members: Vec<String> = self
            .assembly_components
            .iter()
            .filter(|record| component_ids.iter().any(|id| id == &record.id))
            .flat_map(|record| record.solids.iter().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,
        });
        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.transform, world_position);
        Some(format!(
            "{solid_name}@{},{},{}",
            local[0], local[1], local[2]
        ))
    }
}

// ===========================================================================
// Tests — the insert-flow seam, the component projection, the constraint
// lifecycle through the fold, and the vertex-ref builder. All run under the
// synchronous InlineRunner (runner + main share this thread's kernel state,
// exactly like the wasm single-instance case behaves per call).
// ===========================================================================
#[cfg(test)]
mod tests {
    use super::*;

    /// A one-cube part document (the sub-part payload the insert flow embeds).
    fn part_document() -> String {
        serde_json::json!({
            "expressions": "",
            "configurator": {},
            "features": [{
                "type": "P.CU",
                "inputParams": {
                    "id": "Part",
                    "sizeX": 2.0, "sizeY": 3.0, "sizeZ": 4.0,
                    "transform": {
                        "position": [0.0, 0.0, 0.0],
                        "rotationEuler": [0.0, 0.0, 0.0],
                        "scale": [1.0, 1.0, 1.0]
                    },
                    "boolean": { "targets": [], "operation": "NONE" }
                },
                "persistentData": {}
            }]
        })
        .to_string()
    }

    fn fresh_state() -> EngineState {
        brep_kernel::clear_history_cache();
        EngineState::new()
    }

    /// Regression: hitting Solve with NO assembly components must not panic —
    /// an empty assembly must solve to a no-op, not crash the app.
    #[test]
    fn solve_with_no_components_does_not_panic() {
        let mut state = fresh_state();
        let _ = state.assembly_run_solve();
    }

    /// The ONE signature fn is stable across key order (the sorted-key walk)
    /// and sensitive to content — the two properties the `sourceSignature`
    /// up-to-date comparison rests on.
    #[test]
    fn document_signature_is_stable_and_content_sensitive() {
        let a = r#"{"features":[{"type":"P.CU","inputParams":{"id":"Part","sizeX":10}}]}"#;
        // Key order must not matter (stable sorted-key walk)…
        let a_reordered = r#"{"features":[{"inputParams":{"sizeX":10,"id":"Part"},"type":"P.CU"}]}"#;
        // …but content must.
        let b = r#"{"features":[{"type":"P.CU","inputParams":{"id":"Part","sizeX":14}}]}"#;
        assert_eq!(document_signature(a), document_signature(a));
        assert_eq!(document_signature(a), document_signature(a_reordered));
        assert_ne!(document_signature(a), document_signature(b));
    }

    /// THE INSERT SEAM: `add_part_to_library`'s RETURNED effective name lands
    /// in the ACOMP's `inputParams.partName`; the FIRST instance writes
    /// `isFixed: true` explicitly (dialog-visible), later ones `false`; one
    /// library entry backs N instances; the id counter mints `ACOMP<digits>`.
    #[test]
    fn insert_component_seeds_partname_and_first_instance_fixed() {
        let mut state = fresh_state();
        let document = part_document();

        let id1 = state
            .insert_component(ComponentInsert::New {
                name: "bracket",
                source_key: "bracket",
                source_signature: "sig-1",
                document_json: &document,
            })
            .expect("first insert");
        assert_eq!(id1, "ACOMP1", "the counter mints the ACOMP<digits> form");
        let params = state
            .history
            .feature_params(state.history.index_of(&id1).unwrap())
            .unwrap();
        assert_eq!(params["partName"], "bracket");
        assert_eq!(params["isFixed"], true, "first instance is EXPLICITLY fixed");
        assert_eq!(params["transform"]["translate"], serde_json::json!([0, 0, 0]));

        // Second instance from the EXISTING entry — no store read, not fixed.
        let id2 = state
            .insert_component(ComponentInsert::Existing { part_name: "bracket" })
            .expect("second insert");
        assert_eq!(id2, "ACOMP2");
        let params2 = state
            .history
            .feature_params(state.history.index_of(&id2).unwrap())
            .unwrap();
        assert_eq!(params2["partName"], "bracket");
        assert_eq!(params2["isFixed"], false);

        // ONE library entry backs both instances, and the document's
        // partsLibrary block mirrors the store (the SAVE contract).
        assert_eq!(state.parts_library_names(), vec!["bracket".to_string()]);
        let document_value: Value =
            serde_json::from_str(&state.history.request_json()).unwrap();
        assert!(
            document_value["partsLibrary"]["bracket"].is_object(),
            "the request document carries the library block for the runner/save"
        );

        // Re-inserting the SAME content under a different requested name
        // dedups by sourceKey+signature to the existing entry name.
        let id3 = state
            .insert_component(ComponentInsert::New {
                name: "bracket-again",
                source_key: "bracket",
                source_signature: "sig-1",
                document_json: &document,
            })
            .expect("dedup insert");
        let params3 = state
            .history
            .feature_params(state.history.index_of(&id3).unwrap())
            .unwrap();
        assert_eq!(params3["partName"], "bracket", "dedup returns the EXISTING entry name");
        assert_eq!(state.parts_library_names(), vec!["bracket".to_string()]);
    }

    /// The COMPONENT PROJECTION: two instances surface as two ComponentRecords
    /// (deterministic id order, part name + fixed flag + namespaced members),
    /// and deleting instances GCs the library entry when the LAST one goes.
    #[test]
    fn component_projection_and_library_gc_on_last_delete() {
        let mut state = fresh_state();
        let document = part_document();
        state
            .insert_component(ComponentInsert::New {
                name: "bracket",
                source_key: "bracket",
                source_signature: "sig-1",
                document_json: &document,
            })
            .unwrap();
        state
            .insert_component(ComponentInsert::Existing { part_name: "bracket" })
            .unwrap();

        let components = state.assembly_components();
        assert_eq!(components.len(), 2);
        assert_eq!(components[0].id, "ACOMP1");
        assert_eq!(components[1].id, "ACOMP2");
        assert_eq!(components[0].part_name, "bracket");
        assert!(components[0].fixed, "first instance grounded");
        assert!(!components[1].fixed);
        assert_eq!(components[0].solids, vec!["ACOMP1:Part".to_string()]);
        assert_eq!(components[1].solids, vec!["ACOMP2:Part".to_string()]);
        // The members are real display solids.
        assert!(state.scene.solid("ACOMP1:Part").is_some());
        assert!(state.scene.solid("ACOMP2:Part").is_some());

        // Delete one instance: the library entry SURVIVES (one instance left).
        state.delete_feature("ACOMP2");
        assert_eq!(state.assembly_components().len(), 1);
        assert_eq!(state.parts_library_names(), vec!["bracket".to_string()]);

        // Delete the last instance: the entry GCs and the document's
        // partsLibrary block is dropped (no orphaned payload in the file).
        state.delete_feature("ACOMP1");
        assert!(state.assembly_components().is_empty());
        assert!(state.parts_library_names().is_empty(), "orphan entry GC'd");
        let document_value: Value =
            serde_json::from_str(&state.history.request_json()).unwrap();
        assert!(
            document_value.get("partsLibrary").is_none(),
            "no dangling partsLibrary block after the last instance"
        );
    }

    /// Fix/Unfix routes to the OWNING feature's `inputParams.isFixed` and the
    /// re-run reflects it in the projection (one truth, one undo lane).
    #[test]
    fn set_component_fixed_routes_to_the_feature_and_reruns() {
        let mut state = fresh_state();
        state
            .insert_component(ComponentInsert::New {
                name: "bracket",
                source_key: "bracket",
                source_signature: "sig-1",
                document_json: &part_document(),
            })
            .unwrap();
        assert!(state.assembly_components()[0].fixed);

        state.set_component_fixed("ACOMP1", false).expect("unfix");
        let params = state
            .history
            .feature_params(state.history.index_of("ACOMP1").unwrap())
            .unwrap();
        assert_eq!(params["isFixed"], false, "the FEATURE param is the truth");
        assert!(
            !state.assembly_components()[0].fixed,
            "the projection follows after the re-run"
        );
        // Undoable like any model edit.
        state.undo();
        assert!(state.assembly_components()[0].fixed, "undo restores the flag");
    }

    /// Constraint lifecycle through the engine surface: add mints the id from
    /// the type's short name, the fold persists the block into the DOCUMENT,
    /// enable/disable + reorder round-trip, and a mutation is undoable.
    #[test]
    fn constraint_add_toggle_reorder_fold_and_undo() {
        let mut state = fresh_state();
        let document = part_document();
        state
            .insert_component(ComponentInsert::New {
                name: "bracket",
                source_key: "bracket",
                source_signature: "sig-1",
                document_json: &document,
            })
            .unwrap();
        state
            .insert_component(ComponentInsert::Existing { part_name: "bracket" })
            .unwrap();

        // Add: the kernel mints `{SHORT}{counter}`.
        let fixed_id = state
            .assembly_add_constraint(
                "fixed",
                &serde_json::json!({ "elements": ["ACOMP2"] }).to_string(),
            )
            .expect("add fixed");
        assert_eq!(fixed_id, "FIXD1");

        // The FOLD persisted the block into the document (pose-authority
        // contract: adopt BEFORE persisting or re-running).
        let document_value: Value =
            serde_json::from_str(&state.history.request_json()).unwrap();
        assert_eq!(document_value["assembly"]["constraints"][0]["type"], "fixed");
        assert_eq!(
            document_value["assembly"]["constraints"][0]["inputParams"]["id"],
            "FIXD1"
        );

        // Status rows come from the (re-run) session.
        let rows = state.assembly_statuses_value();
        assert_eq!(rows[0]["id"], "FIXD1");
        assert_eq!(rows[0]["enabled"], true);

        // Disable → status "disabled" after the auto-solve.
        state
            .assembly_set_constraint_enabled(&fixed_id, false)
            .expect("disable");
        let rows = state.assembly_statuses_value();
        assert_eq!(rows[0]["enabled"], false);
        assert_eq!(rows[0]["status"], "disabled");

        // A second constraint + reorder to the front.
        let second = state
            .assembly_add_constraint("parallel", "{}")
            .expect("add parallel");
        assert_eq!(second, "PARA2", "the id counter is shared and monotonic");
        state.assembly_move_constraint(&second, 0).expect("reorder");
        let rows = state.assembly_statuses_value();
        assert_eq!(rows[0]["id"], "PARA2");
        assert_eq!(rows[1]["id"], "FIXD1");

        // The reorder was checkpointed: ONE undo restores the old order.
        state.undo();
        let rows = state.assembly_statuses_value();
        assert_eq!(rows[0]["id"], "FIXD1");
        assert_eq!(rows[1]["id"], "PARA2");
    }

    /// The CONSTRAINT flavor of the reference picker commits the picked names
    /// into the constraint's `inputParams.elements` (through the update lane +
    /// fold), not into any feature — the RefSelectTarget routing.
    #[test]
    fn constraint_ref_select_commits_elements_to_the_constraint() {
        let mut state = fresh_state();
        let document = part_document();
        state
            .insert_component(ComponentInsert::New {
                name: "bracket",
                source_key: "bracket",
                source_signature: "sig-1",
                document_json: &document,
            })
            .unwrap();
        state
            .insert_component(ComponentInsert::Existing { part_name: "bracket" })
            .unwrap();
        let id = state.assembly_add_constraint("parallel", "{}").unwrap();

        state.begin_ref_select_for_constraint(
            &id,
            vec!["elements".into()],
            "Elements".into(),
            vec!["FACE".into(), "EDGE".into()],
            true,
            Vec::new(),
        );
        assert!(state.ref_select_active());
        // Simulate two viewport picks (the click path needs a camera; the
        // running name list is the picker's single source of truth).
        state.ref_select.as_mut().unwrap().names =
            vec!["ACOMP1:Part_PZ".to_string(), "ACOMP2:Part_NZ".to_string()];
        state.finish_ref_select();
        assert!(!state.ref_select_active(), "finish exits the modal");

        // The elements landed on the CONSTRAINT (session + document fold).
        let constraint_state = state.assembly_state_value();
        assert_eq!(
            constraint_state["constraints"][0]["inputParams"]["elements"],
            serde_json::json!(["ACOMP1:Part_PZ", "ACOMP2:Part_NZ"])
        );
        let document_value: Value =
            serde_json::from_str(&state.history.request_json()).unwrap();
        assert_eq!(
            document_value["assembly"]["constraints"][0]["inputParams"]["elements"],
            serde_json::json!(["ACOMP1:Part_PZ", "ACOMP2:Part_NZ"]),
            "the fold persisted the committed refs into the document"
        );
    }

    /// The vertex-ref builder: a world pick on a POSED component member maps
    /// through the component's inverse pose into the `{solid}@x,y,z`
    /// COMPONENT-LOCAL form; non-component solids yield none.
    #[test]
    fn component_vertex_ref_is_component_local() {
        let mut state = fresh_state();
        state
            .insert_component(ComponentInsert::New {
                name: "bracket",
                source_key: "bracket",
                source_signature: "sig-1",
                document_json: &part_document(),
            })
            .unwrap();
        // Move the instance to (10, 0, 0) through its feature transform.
        let mut params = state
            .history
            .feature_params(state.history.index_of("ACOMP1").unwrap())
            .unwrap();
        params["transform"]["translate"] = serde_json::json!([10.0, 0.0, 0.0]);
        state
            .update_feature_params("ACOMP1", &params.to_string())
            .unwrap();

        // The part-local corner (2,3,4) sits at world (12,3,4); the ref must
        // carry the LOCAL coordinates.
        let vertex_ref = state
            .component_vertex_ref("ACOMP1:Part", [12.0, 3.0, 4.0])
            .expect("component member yields a ref");
        assert_eq!(vertex_ref, "ACOMP1:Part@2,3,4");
        assert!(
            state.component_vertex_ref("Loose", [0.0; 3]).is_none(),
            "non-component solids build no vertex ref"
        );
    }
}