Skip to main content

brep_render/engine_state/
assembly_ops.rs

1use super::*;
2use serde_json::Value;
3
4// ===========================================================================
5// Assembly surface (Wave-3 lane F) — the engine-state altitude the app's
6// assembly panels program against.
7//
8// # Where the kernel assembly session LIVES (the main-side sync pattern)
9//
10// The kernel's assembly session (constraint state + scene clone, installed by
11// the `execute_history` tail) is THREAD-LOCAL to whichever thread ran the
12// history. The DISPLAY run happens on the runner (native thread / wasm
13// worker), so the main thread's session would be cold. Following the repo's
14// established pattern for main-side kernel-state needs (`export_step_text`,
15// `flat_pattern_target_handle`, `consumed_feature_names` all re-execute the
16// history main-side against the warm incremental cache), [`sync_assembly`]
17// runs `brep_kernel::execute_history` ON THE MAIN THREAD after every applied
18// display run — a cache-hit replay for unchanged features — which installs a
19// fresh main-side session, surfaces the scene's ComponentRecords, and folds
20// the solved state back into the document (the pose-authority contract).
21// Componentless documents skip ALL of it (`history_has_assembly` gates).
22//
23// SEAM (integrator): a runner-protocol extension could move the assembly ABI
24// off-thread later; until then the first sync after opening a heavy assembly
25// re-executes cold on the UI thread (wasm: a one-time stall).
26//
27// # The per-mutation lifecycle (spec §6)
28//
29// Every constraint mutation: (1) ensure the main-side session is current,
30// (2) call the kernel mutation ABI (which auto-solves the session), (3) FOLD
31// the session back into the document (`assembly_apply_document_json` → adopt,
32// checkpointed = undoable), (4) when `settings.assembly_auto_solve` is on,
33// re-run the history through the display runner so the viewport re-poses.
34// With auto-solve off, step 4 waits for the manual Solve button.
35// ===========================================================================
36
37/// What a component-insert names: an EXISTING parts-library entry (skip the
38/// store read — just add an instance) or a NEW part payload to hand to
39/// `add_part_to_library` (which dedups by sourceKey+signature and returns the
40/// EFFECTIVE entry name the instance must reference).
41pub enum ComponentInsert<'a> {
42    Existing {
43        part_name: &'a str,
44    },
45    New {
46        name: &'a str,
47        source_key: &'a str,
48        source_signature: &'a str,
49        document_json: &'a str,
50    },
51}
52
53/// A STABLE content signature of a document: a sorted-key JSON walk hashed
54/// with the (fixed-key, cross-process-deterministic) std SipHash. The ONE
55/// signature fn, at the ENGINE altitude so app-side and engine-side writers
56/// share one copy: the app's insert flow (`panels::file`), the edit-in-place
57/// Finish (`panels::assembly_edit::refresh_library_entry`) and the
58/// update-components comparison (`panels::update_components`) all write/compare
59/// a parts-library `sourceSignature` with THIS function — a freshly inserted,
60/// unchanged part must always compare up-to-date, which a second copy would
61/// break the moment either drifted.
62/// (Distinct from the kernel's `parts_library::stable_json_hash`, which is an
63/// in-memory `doc_hash` for the per-feature fingerprint hook.)
64pub fn document_signature(doc_json: &str) -> String {
65    use std::hash::{Hash, Hasher};
66    fn walk(value: &serde_json::Value, hasher: &mut impl Hasher) {
67        match value {
68            serde_json::Value::Null => 0u8.hash(hasher),
69            serde_json::Value::Bool(flag) => {
70                1u8.hash(hasher);
71                flag.hash(hasher);
72            }
73            serde_json::Value::Number(number) => {
74                2u8.hash(hasher);
75                number.to_string().hash(hasher);
76            }
77            serde_json::Value::String(text) => {
78                3u8.hash(hasher);
79                text.hash(hasher);
80            }
81            serde_json::Value::Array(items) => {
82                4u8.hash(hasher);
83                for item in items {
84                    walk(item, hasher);
85                }
86            }
87            serde_json::Value::Object(map) => {
88                5u8.hash(hasher);
89                let mut keys: Vec<&String> = map.keys().collect();
90                keys.sort();
91                for key in keys {
92                    key.hash(hasher);
93                    walk(&map[key], hasher);
94                }
95            }
96        }
97    }
98    let mut hasher = std::collections::hash_map::DefaultHasher::new();
99    match serde_json::from_str::<serde_json::Value>(doc_json) {
100        Ok(value) => walk(&value, &mut hasher),
101        Err(_) => doc_json.hash(&mut hasher),
102    }
103    format!("{:016x}", hasher.finish())
104}
105
106/// Stringify a kernel ABI error. The kernel exports return `JsValue` errors
107/// (wasm-bindgen, built from plain strings); this crate does not depend on
108/// wasm-bindgen directly, so stringify generically via `Debug` — for a
109/// string-payload `JsValue` that is the message (quoted), which is all the
110/// notice/status lanes need.
111fn js_err<E: std::fmt::Debug>(error: E) -> String {
112    format!("{error:?}")
113}
114
115/// Rigid inverse `[Rᵀ | −Rᵀ·t]` of a component pose — used to express a picked
116/// WORLD point in COMPONENT-LOCAL coordinates (the vertex-ref contract).
117fn rigid_inverse_point(transform: &brep_kernel::AffineTransform, world: [f64; 3]) -> [f64; 3] {
118    let m = &transform.elements;
119    let d = [world[0] - m[3], world[1] - m[7], world[2] - m[11]];
120    [
121        m[0] * d[0] + m[4] * d[1] + m[8] * d[2],
122        m[1] * d[0] + m[5] * d[1] + m[9] * d[2],
123        m[2] * d[0] + m[6] * d[1] + m[10] * d[2],
124    ]
125}
126
127impl EngineState {
128    // --- read surface ------------------------------------------------------
129
130    /// Whether the current document is an ASSEMBLY document: any ACOMP-typed
131    /// feature, or a present `assembly` constraint block. Componentless
132    /// documents skip every main-side sync (zero cost for modeling files).
133    pub fn history_has_assembly(&self) -> bool {
134        let has_acomp = (0..self.history.len()).any(|index| {
135            matches!(
136                self.history.feature_type(index).as_deref(),
137                Some("ACOMP") | Some("ASSEMBLY COMPONENT")
138            )
139        });
140        has_acomp
141            || self
142                .history
143                .assembly_block()
144                .map(|block| !block.is_null())
145                .unwrap_or(false)
146    }
147
148    /// The scene's component records (deterministic id order) as of the last
149    /// [`sync_assembly`] — the Assembly Structure tree's projection source.
150    /// A VIEW over the scene, never an owning structure.
151    pub fn assembly_components(&self) -> &[brep_kernel::ComponentRecord] {
152        &self.assembly_components
153    }
154
155    /// Make sure the main-side assembly session + component projection are
156    /// current with the last APPLIED display run. Cheap no-op when already
157    /// synced (or for componentless documents). Panels call this at frame
158    /// start; the post-run tail (`finish_apply`) also syncs eagerly.
159    pub fn ensure_assembly_synced(&mut self) {
160        if self.assembly_synced_generation == Some(self.applied_generation) {
161            return;
162        }
163        self.sync_assembly();
164    }
165
166    /// The per-constraint status rows (`[{id, type, enabled, open, status,
167    /// message, satisfied, error}]`) from the main-side session.
168    pub fn assembly_statuses_value(&mut self) -> Value {
169        self.ensure_assembly_synced();
170        serde_json::from_str(&brep_kernel::assembly_statuses_json())
171            .unwrap_or_else(|_| Value::Array(Vec::new()))
172    }
173
174    /// The current `assembly` block (post-solve): `{constraints, idCounter}`.
175    pub fn assembly_state_value(&mut self) -> Value {
176        self.ensure_assembly_synced();
177        serde_json::from_str(&brep_kernel::assembly_state_json()).unwrap_or(Value::Null)
178    }
179
180    /// The last solve's DOF/diagnostics summary (`{ok, dof, rank, redundant,
181    /// …}` — `movedSolids` may be absent; tolerate it).
182    pub fn assembly_dof_value(&mut self) -> Value {
183        self.ensure_assembly_synced();
184        serde_json::from_str(&brep_kernel::assembly_dof_json()).unwrap_or(Value::Null)
185    }
186
187    /// Per-constraint overlay rows (world anchors/directions/status/value) —
188    /// the constraints panel reads the evaluated value/unit for the
189    /// distance/angle label suffix; lane G's viewport graphics read the rest.
190    pub fn assembly_overlay_value(&mut self) -> Value {
191        self.ensure_assembly_synced();
192        serde_json::from_str(&brep_kernel::assembly_overlay_json())
193            .unwrap_or_else(|_| Value::Array(Vec::new()))
194    }
195
196    /// The parts-library entry names currently resident (insert-flow "existing
197    /// entries first" list). Main-side store — seeded by document ingest on
198    /// sync and grown by [`insert_component`].
199    pub fn parts_library_names(&mut self) -> Vec<String> {
200        if self.history_has_assembly() {
201            self.ensure_assembly_synced();
202        }
203        serde_json::from_str::<Value>(&brep_kernel::parts_library_json())
204            .ok()
205            .and_then(|value| {
206                value
207                    .as_object()
208                    .map(|map| map.keys().cloned().collect::<Vec<_>>())
209            })
210            .unwrap_or_default()
211    }
212
213    // --- the sync + fold (pose-authority contract) --------------------------
214
215    /// Re-execute the rolled-to history MAIN-SIDE (warm-cache replay), fold
216    /// the resulting ComponentRecords into [`Self::assembly_components`], fold
217    /// the post-solve session state + poses back into the document (silent
218    /// adopt — solver write-back is not a user edit), and refresh the
219    /// document's `partsLibrary` block from the kernel store (post-GC; SAVE
220    /// must serialize the store, never echo a loaded block). Componentless
221    /// documents just clear the projection.
222    pub(crate) fn sync_assembly(&mut self) {
223        if !self.history_has_assembly() {
224            self.assembly_components.clear();
225            // No ACOMP references left ⇒ any partsLibrary block is orphaned
226            // payload (the kernel GCs the store the same way at the end of
227            // every run) — drop it so deleting the last instance never leaves
228            // a dangling entry in the saved document.
229            self.history
230                .set_parts_library(Value::Object(serde_json::Map::new()));
231            self.assembly_synced_generation = Some(self.applied_generation);
232            return;
233        }
234        let request: brep_kernel::HistoryRequest =
235            match serde_json::from_value(self.history.prefix_request()) {
236                Ok(request) => request,
237                Err(_) => return, // unparseable mid-edit document — retry next frame
238            };
239        let result = brep_kernel::execute_history(&request);
240
241        // Fold the per-feature component side-channel exactly like
242        // SceneMap::apply: removals unmap by id, additions insert in order
243        // (BTreeMap ⇒ deterministic id order for the tree/BOM).
244        let mut components: std::collections::BTreeMap<String, brep_kernel::ComponentRecord> =
245            std::collections::BTreeMap::new();
246        for feature in &result.results {
247            for removed in &feature.removed {
248                components.remove(removed);
249            }
250            for record in &feature.components {
251                components.insert(record.id.clone(), record.clone());
252            }
253        }
254        self.assembly_components = components.into_values().collect();
255
256        // Fold the session (solved constraint state + poses/isFixed) into the
257        // document — the pose-authority write-back, silent (not a user edit).
258        self.apply_assembly_fold(false);
259
260        // SAVE-side contract: the document's partsLibrary block mirrors the
261        // kernel store (heals + GC included). Fingerprint-neutral: snapshots
262        // are excluded from the ACOMP content hash.
263        // Read the revision AFTER the run above: its ACOMP self-heal and orphan
264        // GC are library mutations, and they bump it. Serializing the store
265        // costs the whole embedded part payload, so do it only when the library
266        // actually moved — in the steady state (edit, roll, re-solve) it does
267        // not, and this is a single integer compare.
268        let revision = brep_kernel::parts_library_revision();
269        if self.parts_library_block_revision != Some(revision)
270            || !self.history.parts_library_mirrors_store()
271        {
272            if let Ok(library) = serde_json::from_str::<Value>(&brep_kernel::parts_library_json()) {
273                self.history.set_parts_library(library);
274                self.parts_library_block_revision = Some(revision);
275            }
276        }
277        self.assembly_synced_generation = Some(self.applied_generation);
278    }
279
280    /// Run the document fold: `assembly_apply_document_json(document)` →
281    /// adopt the returned document (assembly block replaced; solved poses +
282    /// isFixed folded into features by `inputParams.id`). `checkpoint` = true
283    /// for USER mutations (undoable), false for the silent post-run fold.
284    /// A missing session (no run yet) is tolerated silently.
285    fn apply_assembly_fold(&mut self, checkpoint: bool) {
286        // The fold only rewrites the `assembly` block and per-feature
287        // `inputParams` — it never reads `partsLibrary` — so hand it the
288        // document WITHOUT the library. Otherwise every edit serialized,
289        // parsed and re-serialized the whole embedded part payload three times
290        // over for nothing. `adopt_document` keeps the field when the adopted
291        // document carries no block, so the library survives the round trip.
292        let document = self.history.request_json_without_parts_library();
293        match brep_kernel::assembly_apply_document_json(&document) {
294            Ok(folded) => {
295                let adopted = if checkpoint {
296                    self.history.adopt_document_checkpointed(&folded)
297                } else {
298                    self.history.adopt_document(&folded)
299                };
300                if let Err(error) = adopted {
301                    self.push_notice(format!("assembly fold failed: {error}"));
302                }
303            }
304            Err(_) => {} // no session yet (fresh document before its first run)
305        }
306    }
307
308    /// Shared tail of every USER constraint mutation: fold (checkpointed) and,
309    /// when auto-solve is on, re-run the display history so the viewport
310    /// re-poses (changed ACOMP transforms dirty their fingerprints → those
311    /// instances re-execute + re-tessellate; the runner's tail re-solves).
312    fn after_constraint_mutation(&mut self) {
313        self.apply_assembly_fold(true);
314        if self.settings.assembly_auto_solve {
315            self.rerun_history();
316        } else {
317            self.dirty = true;
318        }
319    }
320
321    // --- constraint mutations (kernel ABI + fold + optional rerun) ----------
322
323    /// Add a constraint (`params_json` = inputParams; the kernel mints the id
324    /// from the type's short name when absent). Returns the minted id.
325    pub fn assembly_add_constraint(
326        &mut self,
327        constraint_type: &str,
328        params_json: &str,
329    ) -> Result<String, String> {
330        self.ensure_assembly_synced();
331        let reply =
332            brep_kernel::assembly_add_constraint_json(constraint_type, params_json).map_err(js_err)?;
333        self.after_constraint_mutation();
334        let id = serde_json::from_str::<Value>(&reply)
335            .ok()
336            .and_then(|value| value.get("id").and_then(|id| id.as_str()).map(String::from))
337            .unwrap_or_default();
338        Ok(id)
339    }
340
341    /// Replace a constraint's `inputParams` (the dialog commit).
342    pub fn assembly_update_constraint(&mut self, id: &str, params_json: &str) -> Result<(), String> {
343        self.ensure_assembly_synced();
344        brep_kernel::assembly_update_constraint_json(id, params_json).map_err(js_err)?;
345        self.after_constraint_mutation();
346        Ok(())
347    }
348
349    /// Update WITHOUT the rerun tail — for callers whose own continuation
350    /// re-runs anyway (the ref-select Finish, whose `end_ref_select` reruns).
351    /// Still folds (checkpointed) so the document is current before that run.
352    pub(crate) fn assembly_update_constraint_no_rerun(
353        &mut self,
354        id: &str,
355        params_json: &str,
356    ) -> Result<(), String> {
357        self.ensure_assembly_synced();
358        brep_kernel::assembly_update_constraint_json(id, params_json).map_err(js_err)?;
359        self.apply_assembly_fold(true);
360        Ok(())
361    }
362
363    /// Delete a constraint.
364    pub fn assembly_remove_constraint(&mut self, id: &str) -> Result<(), String> {
365        self.ensure_assembly_synced();
366        brep_kernel::assembly_remove_constraint_json(id).map_err(js_err)?;
367        self.after_constraint_mutation();
368        Ok(())
369    }
370
371    /// Enable/disable a constraint (the row checkbox).
372    pub fn assembly_set_constraint_enabled(&mut self, id: &str, enabled: bool) -> Result<(), String> {
373        self.ensure_assembly_synced();
374        brep_kernel::assembly_set_constraint_enabled_json(id, enabled).map_err(js_err)?;
375        self.after_constraint_mutation();
376        Ok(())
377    }
378
379    /// Persist WHICH constraint has its dialog open (view state — SILENT fold,
380    /// no solve, no rerun, no undo entry). ACCORDION: at most ONE constraint is
381    /// open — opening one first closes every other inside the same silent fold,
382    /// so the panel toggle, the context bar's add-from-selection, and a viewport
383    /// label click all converge on a single open constraint. The constraints
384    /// PANEL reads this flag as its mode: open one and its form replaces the
385    /// tree (`panels::assembly_constraints`), which is why every one of those
386    /// surfaces opens the dialog without knowing the panel exists.
387    pub fn assembly_set_constraint_open(&mut self, id: &str, open: bool) -> Result<(), String> {
388        self.ensure_assembly_synced();
389        if open {
390            let others: Vec<String> =
391                serde_json::from_str::<Value>(&brep_kernel::assembly_state_json())
392                    .ok()
393                    .and_then(|state| {
394                        state.get("constraints").and_then(|l| l.as_array()).map(|entries| {
395                            entries
396                                .iter()
397                                .filter_map(|entry| {
398                                    let cid =
399                                        entry.get("inputParams")?.get("id")?.as_str()?;
400                                    let is_open =
401                                        entry.get("open").and_then(|v| v.as_bool()).unwrap_or(false);
402                                    (is_open && cid != id).then(|| cid.to_string())
403                                })
404                                .collect()
405                        })
406                    })
407                    .unwrap_or_default();
408            for other in others {
409                brep_kernel::assembly_set_constraint_open_json(&other, false).map_err(js_err)?;
410            }
411        }
412        brep_kernel::assembly_set_constraint_open_json(id, open).map_err(js_err)?;
413        self.apply_assembly_fold(false);
414        Ok(())
415    }
416
417    /// Reorder a constraint to `index` (drag-reorder).
418    pub fn assembly_move_constraint(&mut self, id: &str, index: usize) -> Result<(), String> {
419        self.ensure_assembly_synced();
420        brep_kernel::assembly_move_constraint_json(id, index).map_err(js_err)?;
421        self.after_constraint_mutation();
422        Ok(())
423    }
424
425    /// Manual solve (the panel's Solve button): solve the session, fold, and
426    /// ALWAYS re-run the display (that is the point of pressing Solve — it
427    /// works with auto-solve disabled).
428    pub fn assembly_run_solve(&mut self) -> Result<(), String> {
429        self.ensure_assembly_synced();
430        // Nothing to solve without components — bail BEFORE the kernel solve.
431        // With no assembly session the kernel's solve/fold error paths build a
432        // `JsValue`, and wasm-bindgen's `JsValue` is unimplemented on native
433        // targets: the construction panics ("function not implemented on
434        // non-wasm32 targets") in a nounwind context and ABORTS the desktop
435        // app. An empty assembly solving to a no-op is also the correct result.
436        if self.assembly_components().is_empty() {
437            return Ok(());
438        }
439        brep_kernel::assembly_run_solve_json().map_err(js_err)?;
440        self.apply_assembly_fold(true);
441        self.rerun_history();
442        Ok(())
443    }
444
445    // --- component actions (route to the owning ACOMP feature) --------------
446
447    /// Insert a component instance (the palette/insert flow): resolve the
448    /// parts-library entry (add or reuse), refresh the document's
449    /// `partsLibrary` block, append an ACOMP feature referencing the RETURNED
450    /// effective part name with an identity transform, and re-run. The FIRST
451    /// component of the document writes `isFixed: true` EXPLICITLY (dialog-
452    /// visible); later instances write `false`. Returns the new feature id
453    /// (`ACOMP<digits>` — the history's global counter mints that exact form).
454    pub fn insert_component(&mut self, insert: ComponentInsert<'_>) -> Result<String, String> {
455        let part_name = match insert {
456            ComponentInsert::Existing { part_name } => part_name.to_string(),
457            ComponentInsert::New {
458                name,
459                source_key,
460                source_signature,
461                document_json,
462            } => brep_kernel::add_part_to_library(name, source_key, source_signature, document_json)
463                .map_err(js_err)?,
464        };
465        // The block must ride the request so the display runner ingests the
466        // (new) entry on the very next run.
467        if let Ok(library) = serde_json::from_str::<Value>(&brep_kernel::parts_library_json()) {
468            self.history.set_parts_library(library);
469        }
470        let first = !(0..self.history.len()).any(|index| {
471            matches!(
472                self.history.feature_type(index).as_deref(),
473                Some("ACOMP") | Some("ASSEMBLY COMPONENT")
474            )
475        });
476        let id = self.history.next_feature_id("ACOMP");
477        let feature = serde_json::json!({
478            "type": "ACOMP",
479            "inputParams": {
480                "id": id,
481                "partName": part_name,
482                "transform": { "translate": [0, 0, 0], "rotateEulerDeg": [0, 0, 0] },
483                "isFixed": first,
484            },
485            "persistentData": {}
486        });
487        self.add_feature(&feature.to_string())?;
488        Ok(id)
489    }
490
491    /// Fix/Unfix a component: toggles the OWNING ACOMP feature's
492    /// `inputParams.isFixed` (one truth, one undo lane) and re-runs.
493    pub fn set_component_fixed(&mut self, component_id: &str, fixed: bool) -> Result<(), String> {
494        let index = self
495            .history
496            .index_of(component_id)
497            .ok_or_else(|| format!("no component feature '{component_id}'"))?;
498        let mut params = self
499            .history
500            .feature_params(index)
501            .unwrap_or_else(|| serde_json::json!({}));
502        if let Some(map) = params.as_object_mut() {
503            map.insert("isFixed".into(), Value::Bool(fixed));
504        } else {
505            return Err(format!("component '{component_id}': malformed inputParams"));
506        }
507        self.update_feature_params(component_id, &params.to_string())?;
508        Ok(())
509    }
510
511    /// Select a component in the viewport: emphasis over exactly its member
512    /// solids (the tree↔viewport sync lane; a viewport pick of any member
513    /// lights the tree row through the same emphasis set).
514    pub fn select_component(&mut self, component_id: &str) {
515        self.select_components(&[component_id.to_string()]);
516    }
517
518    /// A component's member SOLID scene names (empty for an unknown id) — the
519    /// selection/hover unit COMPONENT entries resolve to.
520    pub fn component_member_solids(&self, component_id: &str) -> Vec<String> {
521        self.assembly_components
522            .iter()
523            .filter(|record| record.id == component_id)
524            .flat_map(|record| record.solids.iter().cloned())
525            .collect()
526    }
527
528    /// TOGGLE a component in the current selection as ONE unit (the additive
529    /// Ctrl/Cmd+click under COMPONENT promotion): when EVERY member solid is
530    /// already selected the whole set deselects, otherwise the whole set joins
531    /// the selection — the rest of the selection stays.
532    pub fn toggle_component_selection(&mut self, component_id: &str) {
533        let members = self.component_member_solids(component_id);
534        if members.is_empty() {
535            return;
536        }
537        let all_selected = members
538            .iter()
539            .all(|name| self.emphasis.selected_solids.contains(name));
540        for name in members {
541            if all_selected {
542                self.emphasis.selected_solids.remove(&name);
543            } else {
544                self.emphasis.selected_solids.insert(name);
545            }
546        }
547        self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
548        self.dirty = true;
549    }
550
551    /// Select SEVERAL components at once: emphasis over the union of their
552    /// member solids (the interference window's row click highlights both
553    /// participants of a pair through this).
554    pub fn select_components(&mut self, component_ids: &[String]) {
555        let members: Vec<String> = self
556            .assembly_components
557            .iter()
558            .filter(|record| component_ids.iter().any(|id| id == &record.id))
559            .flat_map(|record| record.solids.iter().cloned())
560            .collect();
561        let json = serde_json::json!({ "selected": { "solids": members } }).to_string();
562        let _ = self.emphasis.apply_json(&json);
563        self.dirty = true;
564    }
565
566    // --- constraint reference selection (the ref-select reuse) --------------
567
568    /// Enter the modal reference picker for an ASSEMBLY CONSTRAINT's
569    /// `elements`-style field (same widget, different commit target — Finish
570    /// routes through [`Self::assembly_update_constraint_no_rerun`] instead of
571    /// feature params). No roll-to-before: constraints pick against the FULL
572    /// assembly.
573    pub fn begin_ref_select_for_constraint(
574        &mut self,
575        constraint_id: &str,
576        path: Vec<String>,
577        label: String,
578        filter: Vec<String>,
579        multiple: bool,
580        seed_names: Vec<String>,
581    ) {
582        let restore_index = self.history.rollback();
583        self.selection_filter = SelectionFilter::from_ref_filter(&filter);
584        self.ref_select = Some(RefSelectState {
585            feature_id: constraint_id.to_string(),
586            path,
587            label,
588            filter,
589            multiple,
590            names: seed_names,
591            restore_index,
592            target: RefSelectTarget::AssemblyConstraint,
593        });
594        self.sync_ref_select_emphasis();
595    }
596
597    /// Commit a finished constraint ref-select: read the constraint's params
598    /// from the session state, write the picked names at the field path, and
599    /// update (fold only — the caller's continuation reruns).
600    pub(crate) fn assembly_commit_constraint_refs(
601        &mut self,
602        constraint_id: &str,
603        path: &[String],
604        names: &[String],
605        multiple: bool,
606    ) {
607        let state = self.assembly_state_value();
608        let Some(entry) = state
609            .get("constraints")
610            .and_then(Value::as_array)
611            .and_then(|constraints| {
612                constraints.iter().find(|entry| {
613                    entry
614                        .get("inputParams")
615                        .and_then(|params| params.get("id"))
616                        .and_then(Value::as_str)
617                        == Some(constraint_id)
618                })
619            })
620        else {
621            self.push_notice(format!("unknown constraint '{constraint_id}'"));
622            return;
623        };
624        let mut params = entry
625            .get("inputParams")
626            .cloned()
627            .unwrap_or_else(|| serde_json::json!({}));
628        let value = if multiple {
629            Value::Array(names.iter().cloned().map(Value::String).collect())
630        } else {
631            Value::String(names.first().cloned().unwrap_or_default())
632        };
633        super::selection_ux::set_json_at(&mut params, path, value);
634        if let Err(error) = self.assembly_update_constraint_no_rerun(constraint_id, &params.to_string())
635        {
636            self.push_notice(format!("constraint update failed: {error}"));
637        }
638    }
639
640    /// Build the `{solidName}@x,y,z` COMPONENT-LOCAL vertex ref for a vertex
641    /// pick on a component member (lane-E contract: world pick transformed by
642    /// the owning component's inverse pose). `None` when the solid belongs to
643    /// no component (vertex refs only exist for constraint selection).
644    pub(crate) fn component_vertex_ref(
645        &self,
646        solid_name: &str,
647        world_position: [f64; 3],
648    ) -> Option<String> {
649        let record = self.assembly_components.iter().find(|record| {
650            record.solids.iter().any(|member| member == solid_name)
651        })?;
652        let local = rigid_inverse_point(&record.transform, world_position);
653        Some(format!(
654            "{solid_name}@{},{},{}",
655            local[0], local[1], local[2]
656        ))
657    }
658}
659
660// ===========================================================================
661// Tests — the insert-flow seam, the component projection, the constraint
662// lifecycle through the fold, and the vertex-ref builder. All run under the
663// synchronous InlineRunner (runner + main share this thread's kernel state,
664// exactly like the wasm single-instance case behaves per call).
665// ===========================================================================
666#[cfg(test)]
667mod tests {
668    use super::*;
669
670    /// A one-cube part document (the sub-part payload the insert flow embeds).
671    fn part_document() -> String {
672        serde_json::json!({
673            "expressions": "",
674            "configurator": {},
675            "features": [{
676                "type": "P.CU",
677                "inputParams": {
678                    "id": "Part",
679                    "sizeX": 2.0, "sizeY": 3.0, "sizeZ": 4.0,
680                    "transform": {
681                        "position": [0.0, 0.0, 0.0],
682                        "rotationEuler": [0.0, 0.0, 0.0],
683                        "scale": [1.0, 1.0, 1.0]
684                    },
685                    "boolean": { "targets": [], "operation": "NONE" }
686                },
687                "persistentData": {}
688            }]
689        })
690        .to_string()
691    }
692
693    fn fresh_state() -> EngineState {
694        brep_kernel::clear_history_cache();
695        EngineState::new()
696    }
697
698    /// Regression: hitting Solve with NO assembly components must not panic —
699    /// an empty assembly must solve to a no-op, not crash the app.
700    #[test]
701    fn solve_with_no_components_does_not_panic() {
702        let mut state = fresh_state();
703        let _ = state.assembly_run_solve();
704    }
705
706    /// The ONE signature fn is stable across key order (the sorted-key walk)
707    /// and sensitive to content — the two properties the `sourceSignature`
708    /// up-to-date comparison rests on.
709    #[test]
710    fn document_signature_is_stable_and_content_sensitive() {
711        let a = r#"{"features":[{"type":"P.CU","inputParams":{"id":"Part","sizeX":10}}]}"#;
712        // Key order must not matter (stable sorted-key walk)…
713        let a_reordered = r#"{"features":[{"inputParams":{"sizeX":10,"id":"Part"},"type":"P.CU"}]}"#;
714        // …but content must.
715        let b = r#"{"features":[{"type":"P.CU","inputParams":{"id":"Part","sizeX":14}}]}"#;
716        assert_eq!(document_signature(a), document_signature(a));
717        assert_eq!(document_signature(a), document_signature(a_reordered));
718        assert_ne!(document_signature(a), document_signature(b));
719    }
720
721    /// THE INSERT SEAM: `add_part_to_library`'s RETURNED effective name lands
722    /// in the ACOMP's `inputParams.partName`; the FIRST instance writes
723    /// `isFixed: true` explicitly (dialog-visible), later ones `false`; one
724    /// library entry backs N instances; the id counter mints `ACOMP<digits>`.
725    #[test]
726    fn insert_component_seeds_partname_and_first_instance_fixed() {
727        let mut state = fresh_state();
728        let document = part_document();
729
730        let id1 = state
731            .insert_component(ComponentInsert::New {
732                name: "bracket",
733                source_key: "bracket",
734                source_signature: "sig-1",
735                document_json: &document,
736            })
737            .expect("first insert");
738        assert_eq!(id1, "ACOMP1", "the counter mints the ACOMP<digits> form");
739        let params = state
740            .history
741            .feature_params(state.history.index_of(&id1).unwrap())
742            .unwrap();
743        assert_eq!(params["partName"], "bracket");
744        assert_eq!(params["isFixed"], true, "first instance is EXPLICITLY fixed");
745        assert_eq!(params["transform"]["translate"], serde_json::json!([0, 0, 0]));
746
747        // Second instance from the EXISTING entry — no store read, not fixed.
748        let id2 = state
749            .insert_component(ComponentInsert::Existing { part_name: "bracket" })
750            .expect("second insert");
751        assert_eq!(id2, "ACOMP2");
752        let params2 = state
753            .history
754            .feature_params(state.history.index_of(&id2).unwrap())
755            .unwrap();
756        assert_eq!(params2["partName"], "bracket");
757        assert_eq!(params2["isFixed"], false);
758
759        // ONE library entry backs both instances, and the document's
760        // partsLibrary block mirrors the store (the SAVE contract).
761        assert_eq!(state.parts_library_names(), vec!["bracket".to_string()]);
762        let document_value: Value =
763            serde_json::from_str(&state.history.request_json()).unwrap();
764        assert!(
765            document_value["partsLibrary"]["bracket"].is_object(),
766            "the request document carries the library block for the runner/save"
767        );
768
769        // Re-inserting the SAME content under a different requested name
770        // dedups by sourceKey+signature to the existing entry name.
771        let id3 = state
772            .insert_component(ComponentInsert::New {
773                name: "bracket-again",
774                source_key: "bracket",
775                source_signature: "sig-1",
776                document_json: &document,
777            })
778            .expect("dedup insert");
779        let params3 = state
780            .history
781            .feature_params(state.history.index_of(&id3).unwrap())
782            .unwrap();
783        assert_eq!(params3["partName"], "bracket", "dedup returns the EXISTING entry name");
784        assert_eq!(state.parts_library_names(), vec!["bracket".to_string()]);
785    }
786
787    /// The COMPONENT PROJECTION: two instances surface as two ComponentRecords
788    /// (deterministic id order, part name + fixed flag + namespaced members),
789    /// and deleting instances GCs the library entry when the LAST one goes.
790    #[test]
791    fn component_projection_and_library_gc_on_last_delete() {
792        let mut state = fresh_state();
793        let document = part_document();
794        state
795            .insert_component(ComponentInsert::New {
796                name: "bracket",
797                source_key: "bracket",
798                source_signature: "sig-1",
799                document_json: &document,
800            })
801            .unwrap();
802        state
803            .insert_component(ComponentInsert::Existing { part_name: "bracket" })
804            .unwrap();
805
806        let components = state.assembly_components();
807        assert_eq!(components.len(), 2);
808        assert_eq!(components[0].id, "ACOMP1");
809        assert_eq!(components[1].id, "ACOMP2");
810        assert_eq!(components[0].part_name, "bracket");
811        assert!(components[0].fixed, "first instance grounded");
812        assert!(!components[1].fixed);
813        assert_eq!(components[0].solids, vec!["ACOMP1:Part".to_string()]);
814        assert_eq!(components[1].solids, vec!["ACOMP2:Part".to_string()]);
815        // The members are real display solids.
816        assert!(state.scene.solid("ACOMP1:Part").is_some());
817        assert!(state.scene.solid("ACOMP2:Part").is_some());
818
819        // Delete one instance: the library entry SURVIVES (one instance left).
820        state.delete_feature("ACOMP2");
821        assert_eq!(state.assembly_components().len(), 1);
822        assert_eq!(state.parts_library_names(), vec!["bracket".to_string()]);
823
824        // Delete the last instance: the entry GCs and the document's
825        // partsLibrary block is dropped (no orphaned payload in the file).
826        state.delete_feature("ACOMP1");
827        assert!(state.assembly_components().is_empty());
828        assert!(state.parts_library_names().is_empty(), "orphan entry GC'd");
829        let document_value: Value =
830            serde_json::from_str(&state.history.request_json()).unwrap();
831        assert!(
832            document_value.get("partsLibrary").is_none(),
833            "no dangling partsLibrary block after the last instance"
834        );
835    }
836
837    /// Fix/Unfix routes to the OWNING feature's `inputParams.isFixed` and the
838    /// re-run reflects it in the projection (one truth, one undo lane).
839    #[test]
840    fn set_component_fixed_routes_to_the_feature_and_reruns() {
841        let mut state = fresh_state();
842        state
843            .insert_component(ComponentInsert::New {
844                name: "bracket",
845                source_key: "bracket",
846                source_signature: "sig-1",
847                document_json: &part_document(),
848            })
849            .unwrap();
850        assert!(state.assembly_components()[0].fixed);
851
852        state.set_component_fixed("ACOMP1", false).expect("unfix");
853        let params = state
854            .history
855            .feature_params(state.history.index_of("ACOMP1").unwrap())
856            .unwrap();
857        assert_eq!(params["isFixed"], false, "the FEATURE param is the truth");
858        assert!(
859            !state.assembly_components()[0].fixed,
860            "the projection follows after the re-run"
861        );
862        // Undoable like any model edit.
863        state.undo();
864        assert!(state.assembly_components()[0].fixed, "undo restores the flag");
865    }
866
867    /// Constraint lifecycle through the engine surface: add mints the id from
868    /// the type's short name, the fold persists the block into the DOCUMENT,
869    /// enable/disable + reorder round-trip, and a mutation is undoable.
870    #[test]
871    fn constraint_add_toggle_reorder_fold_and_undo() {
872        let mut state = fresh_state();
873        let document = part_document();
874        state
875            .insert_component(ComponentInsert::New {
876                name: "bracket",
877                source_key: "bracket",
878                source_signature: "sig-1",
879                document_json: &document,
880            })
881            .unwrap();
882        state
883            .insert_component(ComponentInsert::Existing { part_name: "bracket" })
884            .unwrap();
885
886        // Add: the kernel mints `{SHORT}{counter}`.
887        let fixed_id = state
888            .assembly_add_constraint(
889                "fixed",
890                &serde_json::json!({ "elements": ["ACOMP2"] }).to_string(),
891            )
892            .expect("add fixed");
893        assert_eq!(fixed_id, "FIXD1");
894
895        // The FOLD persisted the block into the document (pose-authority
896        // contract: adopt BEFORE persisting or re-running).
897        let document_value: Value =
898            serde_json::from_str(&state.history.request_json()).unwrap();
899        assert_eq!(document_value["assembly"]["constraints"][0]["type"], "fixed");
900        assert_eq!(
901            document_value["assembly"]["constraints"][0]["inputParams"]["id"],
902            "FIXD1"
903        );
904
905        // Status rows come from the (re-run) session.
906        let rows = state.assembly_statuses_value();
907        assert_eq!(rows[0]["id"], "FIXD1");
908        assert_eq!(rows[0]["enabled"], true);
909
910        // Disable → status "disabled" after the auto-solve.
911        state
912            .assembly_set_constraint_enabled(&fixed_id, false)
913            .expect("disable");
914        let rows = state.assembly_statuses_value();
915        assert_eq!(rows[0]["enabled"], false);
916        assert_eq!(rows[0]["status"], "disabled");
917
918        // A second constraint + reorder to the front.
919        let second = state
920            .assembly_add_constraint("parallel", "{}")
921            .expect("add parallel");
922        assert_eq!(second, "PARA2", "the id counter is shared and monotonic");
923        state.assembly_move_constraint(&second, 0).expect("reorder");
924        let rows = state.assembly_statuses_value();
925        assert_eq!(rows[0]["id"], "PARA2");
926        assert_eq!(rows[1]["id"], "FIXD1");
927
928        // The reorder was checkpointed: ONE undo restores the old order.
929        state.undo();
930        let rows = state.assembly_statuses_value();
931        assert_eq!(rows[0]["id"], "FIXD1");
932        assert_eq!(rows[1]["id"], "PARA2");
933    }
934
935    /// The CONSTRAINT flavor of the reference picker commits the picked names
936    /// into the constraint's `inputParams.elements` (through the update lane +
937    /// fold), not into any feature — the RefSelectTarget routing.
938    #[test]
939    fn constraint_ref_select_commits_elements_to_the_constraint() {
940        let mut state = fresh_state();
941        let document = part_document();
942        state
943            .insert_component(ComponentInsert::New {
944                name: "bracket",
945                source_key: "bracket",
946                source_signature: "sig-1",
947                document_json: &document,
948            })
949            .unwrap();
950        state
951            .insert_component(ComponentInsert::Existing { part_name: "bracket" })
952            .unwrap();
953        let id = state.assembly_add_constraint("parallel", "{}").unwrap();
954
955        state.begin_ref_select_for_constraint(
956            &id,
957            vec!["elements".into()],
958            "Elements".into(),
959            vec!["FACE".into(), "EDGE".into()],
960            true,
961            Vec::new(),
962        );
963        assert!(state.ref_select_active());
964        // Simulate two viewport picks (the click path needs a camera; the
965        // running name list is the picker's single source of truth).
966        state.ref_select.as_mut().unwrap().names =
967            vec!["ACOMP1:Part_PZ".to_string(), "ACOMP2:Part_NZ".to_string()];
968        state.finish_ref_select();
969        assert!(!state.ref_select_active(), "finish exits the modal");
970
971        // The elements landed on the CONSTRAINT (session + document fold).
972        let constraint_state = state.assembly_state_value();
973        assert_eq!(
974            constraint_state["constraints"][0]["inputParams"]["elements"],
975            serde_json::json!(["ACOMP1:Part_PZ", "ACOMP2:Part_NZ"])
976        );
977        let document_value: Value =
978            serde_json::from_str(&state.history.request_json()).unwrap();
979        assert_eq!(
980            document_value["assembly"]["constraints"][0]["inputParams"]["elements"],
981            serde_json::json!(["ACOMP1:Part_PZ", "ACOMP2:Part_NZ"]),
982            "the fold persisted the committed refs into the document"
983        );
984    }
985
986    /// The vertex-ref builder: a world pick on a POSED component member maps
987    /// through the component's inverse pose into the `{solid}@x,y,z`
988    /// COMPONENT-LOCAL form; non-component solids yield none.
989    #[test]
990    fn component_vertex_ref_is_component_local() {
991        let mut state = fresh_state();
992        state
993            .insert_component(ComponentInsert::New {
994                name: "bracket",
995                source_key: "bracket",
996                source_signature: "sig-1",
997                document_json: &part_document(),
998            })
999            .unwrap();
1000        // Move the instance to (10, 0, 0) through its feature transform.
1001        let mut params = state
1002            .history
1003            .feature_params(state.history.index_of("ACOMP1").unwrap())
1004            .unwrap();
1005        params["transform"]["translate"] = serde_json::json!([10.0, 0.0, 0.0]);
1006        state
1007            .update_feature_params("ACOMP1", &params.to_string())
1008            .unwrap();
1009
1010        // The part-local corner (2,3,4) sits at world (12,3,4); the ref must
1011        // carry the LOCAL coordinates.
1012        let vertex_ref = state
1013            .component_vertex_ref("ACOMP1:Part", [12.0, 3.0, 4.0])
1014            .expect("component member yields a ref");
1015        assert_eq!(vertex_ref, "ACOMP1:Part@2,3,4");
1016        assert!(
1017            state.component_vertex_ref("Loose", [0.0; 3]).is_none(),
1018            "non-component solids build no vertex ref"
1019        );
1020    }
1021}