Skip to main content

brep_render/engine_state/
assembly_ops.rs

1use super::*;
2use serde_json::Value;
3
4// Assembly sessions are thread-local. After a display run on the background
5// runner, sync_assembly replays the history on the main thread to populate its
6// session and fold solved component state into the document. Componentless
7// histories skip this work; the first cold sync can block the UI.
8// Mutations synchronize, call the kernel ABI, and adopt the result with an undo
9// checkpoint. Auto-solve reruns the display history; otherwise manual Solve does.
10
11/// What a component-insert names: an EXISTING parts-library entry (skip the
12/// store read — just add an instance) or a NEW part payload to hand to
13/// `add_part_to_library` (which dedups by sourceKey+signature and returns the
14/// EFFECTIVE entry name the instance must reference).
15pub enum ComponentInsert<'a> {
16    Existing {
17        part_name: &'a str,
18    },
19    New {
20        name: &'a str,
21        source_key: &'a str,
22        source_signature: &'a str,
23        document_json: &'a str,
24    },
25}
26
27/// A STABLE content signature of a document: a sorted-key JSON walk hashed
28/// with the (fixed-key, cross-process-deterministic) std SipHash. The ONE
29/// signature fn, at the ENGINE altitude so app-side and engine-side writers
30/// share one copy: the app's insert flow (`panels::file`), the edit-in-place
31/// Finish (`panels::assembly_edit::refresh_library_entry`) and the
32/// update-components comparison (`panels::update_components`) all write/compare
33/// a parts-library `sourceSignature` with THIS function — a freshly inserted,
34/// unchanged part must always compare up-to-date, which a second copy would
35/// break the moment either drifted.
36/// Uses the kernel's shared sorted-key hash, also used for in-memory library
37/// fingerprints. Invalid JSON retains the raw-text hashing fallback.
38pub fn document_signature(doc_json: &str) -> String {
39    use std::hash::{Hash, Hasher};
40    let hash = match serde_json::from_str::<serde_json::Value>(doc_json) {
41        Ok(value) => brep_kernel::stable_json_hash(&value),
42        Err(_) => {
43            let mut hasher = std::collections::hash_map::DefaultHasher::new();
44            doc_json.hash(&mut hasher);
45            hasher.finish()
46        }
47    };
48    format!("{hash:016x}")
49}
50
51/// Stringify a kernel ABI error. The kernel exports return `JsValue` errors
52/// (wasm-bindgen, built from plain strings); this crate does not depend on
53/// wasm-bindgen directly, so stringify generically via `Debug` — for a
54/// string-payload `JsValue` that is the message (quoted), which is all the
55/// notice/status lanes need.
56fn js_err<E: std::fmt::Debug>(error: E) -> String {
57    format!("{error:?}")
58}
59
60/// Rigid inverse `[Rᵀ | −Rᵀ·t]` of a component pose — used to express a picked
61/// WORLD point in COMPONENT-LOCAL coordinates (the vertex-ref contract).
62fn rigid_inverse_point(transform: &brep_kernel::AffineTransform, world: [f64; 3]) -> [f64; 3] {
63    let m = &transform.elements;
64    let d = [world[0] - m[3], world[1] - m[7], world[2] - m[11]];
65    [
66        m[0] * d[0] + m[4] * d[1] + m[8] * d[2],
67        m[1] * d[0] + m[5] * d[1] + m[9] * d[2],
68        m[2] * d[0] + m[6] * d[1] + m[10] * d[2],
69    ]
70}
71
72impl EngineState {
73    // --- read surface ------------------------------------------------------
74
75    /// Whether the current document is an ASSEMBLY document: any ACOMP-typed
76    /// feature, or a present `assembly` constraint block. Componentless
77    /// documents skip every main-side sync (zero cost for modeling files).
78    pub fn history_has_assembly(&self) -> bool {
79        let has_acomp = (0..self.history.len()).any(|index| {
80            matches!(
81                self.history.feature_type(index).as_deref(),
82                Some("ACOMP") | Some("ASSEMBLY COMPONENT")
83            )
84        });
85        has_acomp
86            || self
87                .history
88                .assembly_block()
89                .map(|block| !block.is_null())
90                .unwrap_or(false)
91    }
92
93    /// The scene's component records (deterministic id order) as of the last
94    /// [`sync_assembly`] — the Assembly Structure tree's projection source.
95    /// A VIEW over the scene, never an owning structure.
96    pub fn assembly_components(&self) -> &[brep_kernel::ComponentRecord] {
97        &self.assembly_components
98    }
99
100    /// Make sure the main-side assembly session + component projection are
101    /// current with the last APPLIED display run. Cheap no-op when already
102    /// synced (or for componentless documents). Panels call this at frame
103    /// start; the post-run tail (`finish_apply`) also syncs eagerly.
104    pub fn ensure_assembly_synced(&mut self) {
105        if self.assembly_synced_generation == Some(self.applied_generation) {
106            return;
107        }
108        self.sync_assembly();
109    }
110
111    /// The per-constraint status rows (`[{id, type, enabled, open, status,
112    /// message, satisfied, error}]`) from the main-side session.
113    pub fn assembly_statuses_value(&mut self) -> Value {
114        self.ensure_assembly_synced();
115        serde_json::from_str(&brep_kernel::assembly_statuses_json())
116            .unwrap_or_else(|_| Value::Array(Vec::new()))
117    }
118
119    /// The current `assembly` block (post-solve): `{constraints, idCounter}`.
120    pub fn assembly_state_value(&mut self) -> Value {
121        self.ensure_assembly_synced();
122        serde_json::from_str(&brep_kernel::assembly_state_json()).unwrap_or(Value::Null)
123    }
124
125    /// The last solve's DOF/diagnostics summary (`{ok, dof, rank, redundant,
126    /// …}` — `movedSolids` may be absent; tolerate it).
127    pub fn assembly_dof_value(&mut self) -> Value {
128        self.ensure_assembly_synced();
129        serde_json::from_str(&brep_kernel::assembly_dof_json()).unwrap_or(Value::Null)
130    }
131
132    /// Per-constraint overlay rows (world anchors/directions/status/value) —
133    /// the constraints panel reads the evaluated value/unit for the
134    /// distance/angle label suffix; lane G's viewport graphics read the rest.
135    pub fn assembly_overlay_value(&mut self) -> Value {
136        self.ensure_assembly_synced();
137        serde_json::from_str(&brep_kernel::assembly_overlay_json())
138            .unwrap_or_else(|_| Value::Array(Vec::new()))
139    }
140
141    /// The parts-library entry names currently resident (insert-flow "existing
142    /// entries first" list). Main-side store — seeded by document ingest on
143    /// sync and grown by [`insert_component`].
144    pub fn parts_library_names(&mut self) -> Vec<String> {
145        if self.history_has_assembly() {
146            self.ensure_assembly_synced();
147        }
148        serde_json::from_str::<Value>(&brep_kernel::parts_library_json())
149            .ok()
150            .and_then(|value| {
151                value
152                    .as_object()
153                    .map(|map| map.keys().cloned().collect::<Vec<_>>())
154            })
155            .unwrap_or_default()
156    }
157
158    // --- the sync + fold (pose-authority contract) --------------------------
159
160    /// Re-execute the rolled-to history MAIN-SIDE (warm-cache replay), fold
161    /// the resulting ComponentRecords into [`Self::assembly_components`], fold
162    /// the post-solve session state + poses back into the document (silent
163    /// adopt — solver write-back is not a user edit), and refresh the
164    /// document's `partsLibrary` block from the kernel store (post-GC; SAVE
165    /// must serialize the store, never echo a loaded block). Componentless
166    /// documents just clear the projection.
167    pub(crate) fn sync_assembly(&mut self) {
168        if !self.history_has_assembly() {
169            self.assembly_components.clear();
170            // No ACOMP references left ⇒ any partsLibrary block is orphaned
171            // payload (the kernel GCs the store the same way at the end of
172            // every run) — drop it so deleting the last instance never leaves
173            // a dangling entry in the saved document.
174            self.history
175                .set_parts_library(Value::Object(serde_json::Map::new()));
176            self.assembly_synced_generation = Some(self.applied_generation);
177            return;
178        }
179        let request: brep_kernel::HistoryRequest =
180            match serde_json::from_value(self.history.prefix_request()) {
181                Ok(request) => request,
182                Err(_) => return, // unparseable mid-edit document — retry next frame
183            };
184        let result = brep_kernel::execute_history(&request);
185
186        // Fold the per-feature component side-channel exactly like
187        // SceneMap::apply: removals unmap by id, additions insert in order
188        // (BTreeMap ⇒ deterministic id order for the tree/BOM).
189        let mut components: std::collections::BTreeMap<String, brep_kernel::ComponentRecord> =
190            std::collections::BTreeMap::new();
191        for feature in &result.results {
192            for removed in &feature.removed {
193                components.remove(removed);
194            }
195            for record in &feature.components {
196                components.insert(record.id.clone(), record.clone());
197            }
198        }
199        self.assembly_components = components.into_values().collect();
200
201        // Fold the session (solved constraint state + poses/isFixed) into the
202        // document — the pose-authority write-back, silent (not a user edit).
203        self.apply_assembly_fold(false);
204
205        // SAVE-side contract: the document's partsLibrary block mirrors the
206        // kernel store (heals + GC included). Fingerprint-neutral: snapshots
207        // are excluded from the ACOMP content hash.
208        // Read the revision AFTER the run above: its ACOMP self-heal and orphan
209        // GC are library mutations, and they bump it. Serializing the store
210        // costs the whole embedded part payload, so do it only when the library
211        // actually moved — in the steady state (edit, roll, re-solve) it does
212        // not, and this is a single integer compare.
213        let revision = brep_kernel::parts_library_revision();
214        if self.parts_library_block_revision != Some(revision)
215            || !self.history.parts_library_mirrors_store()
216        {
217            if let Ok(library) = serde_json::from_str::<Value>(&brep_kernel::parts_library_json()) {
218                self.history.set_parts_library(library);
219                self.parts_library_block_revision = Some(revision);
220            }
221        }
222        self.assembly_synced_generation = Some(self.applied_generation);
223    }
224
225    /// Run the document fold: `assembly_apply_document_json(document)` →
226    /// adopt the returned document (assembly block replaced; solved poses +
227    /// isFixed folded into features by `inputParams.id`). `checkpoint` = true
228    /// for USER mutations (undoable), false for the silent post-run fold.
229    /// A missing session (no run yet) is tolerated silently.
230    fn apply_assembly_fold(&mut self, checkpoint: bool) {
231        // The fold only rewrites the `assembly` block and per-feature
232        // `inputParams` — it never reads `partsLibrary` — so hand it the
233        // document WITHOUT the library. Otherwise every edit serialized,
234        // parsed and re-serialized the whole embedded part payload three times
235        // over for nothing. `adopt_document` keeps the field when the adopted
236        // document carries no block, so the library survives the round trip.
237        let document = self.history.request_json_without_parts_library();
238        match brep_kernel::assembly_apply_document_json(&document) {
239            Ok(folded) => {
240                let adopted = if checkpoint {
241                    self.history.adopt_document_checkpointed(&folded)
242                } else {
243                    self.history.adopt_document(&folded)
244                };
245                if let Err(error) = adopted {
246                    self.push_notice(format!("assembly fold failed: {error}"));
247                }
248            }
249            Err(_) => {} // no session yet (fresh document before its first run)
250        }
251    }
252
253    /// Shared tail of every USER constraint mutation: fold (checkpointed) and,
254    /// when auto-solve is on, re-run the display history so the viewport
255    /// re-poses (changed ACOMP transforms dirty their fingerprints → those
256    /// instances re-execute + re-tessellate; the runner's tail re-solves).
257    fn after_constraint_mutation(&mut self) {
258        self.apply_assembly_fold(true);
259        if self.settings.assembly_auto_solve {
260            self.rerun_history();
261        } else {
262            self.dirty = true;
263        }
264    }
265
266    // --- constraint mutations (kernel ABI + fold + optional rerun) ----------
267
268    /// Add a constraint (`params_json` = inputParams; the kernel mints the id
269    /// from the type's short name when absent). Returns the minted id.
270    pub fn assembly_add_constraint(
271        &mut self,
272        constraint_type: &str,
273        params_json: &str,
274    ) -> Result<String, String> {
275        self.ensure_assembly_synced();
276        let reply =
277            brep_kernel::assembly_add_constraint_json(constraint_type, params_json).map_err(js_err)?;
278        self.after_constraint_mutation();
279        let id = serde_json::from_str::<Value>(&reply)
280            .ok()
281            .and_then(|value| value.get("id").and_then(|id| id.as_str()).map(String::from))
282            .unwrap_or_default();
283        Ok(id)
284    }
285
286    /// Replace a constraint's `inputParams` (the dialog commit).
287    pub fn assembly_update_constraint(&mut self, id: &str, params_json: &str) -> Result<(), String> {
288        self.ensure_assembly_synced();
289        brep_kernel::assembly_update_constraint_json(id, params_json).map_err(js_err)?;
290        self.after_constraint_mutation();
291        Ok(())
292    }
293
294    /// Update WITHOUT the rerun tail — for callers whose own continuation
295    /// re-runs anyway (the ref-select Finish, whose `end_ref_select` reruns).
296    /// Still folds (checkpointed) so the document is current before that run.
297    pub(crate) fn assembly_update_constraint_no_rerun(
298        &mut self,
299        id: &str,
300        params_json: &str,
301    ) -> Result<(), String> {
302        self.ensure_assembly_synced();
303        brep_kernel::assembly_update_constraint_json(id, params_json).map_err(js_err)?;
304        self.apply_assembly_fold(true);
305        Ok(())
306    }
307
308    /// Delete a constraint.
309    pub fn assembly_remove_constraint(&mut self, id: &str) -> Result<(), String> {
310        self.ensure_assembly_synced();
311        brep_kernel::assembly_remove_constraint_json(id).map_err(js_err)?;
312        self.after_constraint_mutation();
313        Ok(())
314    }
315
316    /// Enable/disable a constraint (the row checkbox).
317    pub fn assembly_set_constraint_enabled(&mut self, id: &str, enabled: bool) -> Result<(), String> {
318        self.ensure_assembly_synced();
319        brep_kernel::assembly_set_constraint_enabled_json(id, enabled).map_err(js_err)?;
320        self.after_constraint_mutation();
321        Ok(())
322    }
323
324    /// Persist WHICH constraint has its dialog open (view state — SILENT fold,
325    /// no solve, no rerun, no undo entry). ACCORDION: at most ONE constraint is
326    /// open — opening one first closes every other inside the same silent fold,
327    /// so the panel toggle, the context bar's add-from-selection, and a viewport
328    /// label click all converge on a single open constraint. The constraints
329    /// PANEL reads this flag as its mode: open one and its form replaces the
330    /// tree (`panels::assembly_constraints`), which is why every one of those
331    /// surfaces opens the dialog without knowing the panel exists.
332    pub fn assembly_set_constraint_open(&mut self, id: &str, open: bool) -> Result<(), String> {
333        self.ensure_assembly_synced();
334        if open {
335            let others: Vec<String> =
336                serde_json::from_str::<Value>(&brep_kernel::assembly_state_json())
337                    .ok()
338                    .and_then(|state| {
339                        state.get("constraints").and_then(|l| l.as_array()).map(|entries| {
340                            entries
341                                .iter()
342                                .filter_map(|entry| {
343                                    let cid =
344                                        entry.get("inputParams")?.get("id")?.as_str()?;
345                                    let is_open =
346                                        entry.get("open").and_then(|v| v.as_bool()).unwrap_or(false);
347                                    (is_open && cid != id).then(|| cid.to_string())
348                                })
349                                .collect()
350                        })
351                    })
352                    .unwrap_or_default();
353            for other in others {
354                brep_kernel::assembly_set_constraint_open_json(&other, false).map_err(js_err)?;
355            }
356        }
357        brep_kernel::assembly_set_constraint_open_json(id, open).map_err(js_err)?;
358        self.apply_assembly_fold(false);
359        Ok(())
360    }
361
362    /// Reorder a constraint to `index` (drag-reorder).
363    pub fn assembly_move_constraint(&mut self, id: &str, index: usize) -> Result<(), String> {
364        self.ensure_assembly_synced();
365        brep_kernel::assembly_move_constraint_json(id, index).map_err(js_err)?;
366        self.after_constraint_mutation();
367        Ok(())
368    }
369
370    // --- automatic inference (the Auto Constraints button) ------------------
371
372    /// The constraint types the inference lane can produce — `[{type, label,
373    /// icon, longName, detects, defaultOn}]`, straight off the kernel's rule
374    /// table. The dialog lists THIS; it keeps no roster of its own, so a rule
375    /// added in the kernel shows up as a row without an app change.
376    pub fn assembly_inferable_types(&self) -> Value {
377        serde_json::from_str(&brep_kernel::assembly_inferable_types_json())
378            .unwrap_or_else(|_| Value::Array(Vec::new()))
379    }
380
381    /// SCAN the placed components for the constraints their placement implies,
382    /// creating nothing. `options` is the kernel's `InferOptions` JSON (`{}` for
383    /// the defaults; the dialog sends the ticked `types`).
384    pub fn assembly_infer_constraints(&mut self, options: &str) -> Value {
385        self.ensure_assembly_synced();
386        serde_json::from_str(&brep_kernel::assembly_infer_constraints_json(options))
387            .unwrap_or(Value::Null)
388    }
389
390    /// Scan and CREATE, as one undoable step: the kernel adds every accepted
391    /// candidate and solves the batch once, then the usual mutation tail folds
392    /// the result into the document and re-runs the display. A scan that found
393    /// nothing skips the tail entirely (no checkpoint, no rerun).
394    pub fn assembly_apply_inferred_constraints(&mut self, options: &str) -> Value {
395        self.ensure_assembly_synced();
396        let reply: Value =
397            serde_json::from_str(&brep_kernel::assembly_apply_inferred_constraints_json(options))
398                .unwrap_or(Value::Null);
399        let created = reply
400            .get("created")
401            .and_then(|value| value.as_array())
402            .map(|list| list.len())
403            .unwrap_or(0);
404        if created > 0 {
405            self.after_constraint_mutation();
406        }
407        reply
408    }
409
410    /// Manual solve (the panel's Solve button): solve the session, fold, and
411    /// ALWAYS re-run the display (that is the point of pressing Solve — it
412    /// works with auto-solve disabled).
413    pub fn assembly_run_solve(&mut self) -> Result<(), String> {
414        self.ensure_assembly_synced();
415        // Nothing to solve without components — bail BEFORE the kernel solve.
416        // With no assembly session the kernel's solve/fold error paths build a
417        // `JsValue`, and wasm-bindgen's `JsValue` is unimplemented on native
418        // targets: the construction panics ("function not implemented on
419        // non-wasm32 targets") in a nounwind context and ABORTS the desktop
420        // app. An empty assembly solving to a no-op is also the correct result.
421        if self.assembly_components().is_empty() {
422            return Ok(());
423        }
424        brep_kernel::assembly_run_solve_json().map_err(js_err)?;
425        self.apply_assembly_fold(true);
426        self.rerun_history();
427        Ok(())
428    }
429
430    // --- component actions (route to the owning ACOMP feature) --------------
431
432    /// Insert a component instance (the palette/insert flow): resolve the
433    /// parts-library entry (add or reuse), refresh the document's
434    /// `partsLibrary` block, append an ACOMP feature referencing the RETURNED
435    /// effective part name with an identity transform, and re-run. The FIRST
436    /// component of the document writes `isFixed: true` EXPLICITLY (dialog-
437    /// visible); later instances write `false`. Returns the new feature id
438    /// (`ACOMP<digits>` — the history's global counter mints that exact form).
439    pub fn insert_component(&mut self, insert: ComponentInsert<'_>) -> Result<String, String> {
440        let part_name = match insert {
441            ComponentInsert::Existing { part_name } => part_name.to_string(),
442            ComponentInsert::New {
443                name,
444                source_key,
445                source_signature,
446                document_json,
447            } => brep_kernel::add_part_to_library(name, source_key, source_signature, document_json)
448                .map_err(js_err)?,
449        };
450        // The block must ride the request so the display runner ingests the
451        // (new) entry on the very next run.
452        if let Ok(library) = serde_json::from_str::<Value>(&brep_kernel::parts_library_json()) {
453            self.history.set_parts_library(library);
454        }
455        let first = !(0..self.history.len()).any(|index| {
456            matches!(
457                self.history.feature_type(index).as_deref(),
458                Some("ACOMP") | Some("ASSEMBLY COMPONENT")
459            )
460        });
461        let id = self.history.next_feature_id("ACOMP");
462        let feature = serde_json::json!({
463            "type": "ACOMP",
464            "inputParams": {
465                "id": id,
466                "partName": part_name,
467                "transform": { "translate": [0, 0, 0], "rotateEulerDeg": [0, 0, 0] },
468                "isFixed": first,
469            },
470            "persistentData": {}
471        });
472        self.add_feature(&feature.to_string())?;
473        Ok(id)
474    }
475
476    /// Fix/Unfix a component: toggles the OWNING ACOMP feature's
477    /// `inputParams.isFixed` (one truth, one undo lane) and re-runs.
478    pub fn set_component_fixed(&mut self, component_id: &str, fixed: bool) -> Result<(), String> {
479        let index = self
480            .history
481            .index_of(component_id)
482            .ok_or_else(|| format!("no component feature '{component_id}'"))?;
483        let mut params = self
484            .history
485            .feature_params(index)
486            .unwrap_or_else(|| serde_json::json!({}));
487        if let Some(map) = params.as_object_mut() {
488            map.insert("isFixed".into(), Value::Bool(fixed));
489        } else {
490            return Err(format!("component '{component_id}': malformed inputParams"));
491        }
492        self.update_feature_params(component_id, &params.to_string())?;
493        Ok(())
494    }
495
496    /// Select a component in the viewport: emphasis over exactly its member
497    /// solids (the tree↔viewport sync lane; a viewport pick of any member
498    /// lights the tree row through the same emphasis set).
499    pub fn select_component(&mut self, component_id: &str) {
500        self.select_components(&[component_id.to_string()]);
501    }
502
503    /// A component's member SOLID scene names (empty for an unknown id) — the
504    /// selection/hover unit COMPONENT entries resolve to.
505    pub fn component_member_solids(&self, component_id: &str) -> Vec<String> {
506        self.assembly_components
507            .iter()
508            .filter(|record| record.id == component_id)
509            .flat_map(|record| record.solids.iter().cloned())
510            .collect()
511    }
512
513    /// TOGGLE a component in the current selection as ONE unit (the additive
514    /// Ctrl/Cmd+click under COMPONENT promotion): when EVERY member solid is
515    /// already selected the whole set deselects, otherwise the whole set joins
516    /// the selection — the rest of the selection stays.
517    pub fn toggle_component_selection(&mut self, component_id: &str) {
518        let members = self.component_member_solids(component_id);
519        if members.is_empty() {
520            return;
521        }
522        let all_selected = members
523            .iter()
524            .all(|name| self.emphasis.selected_solids.contains(name));
525        for name in members {
526            if all_selected {
527                self.emphasis.selected_solids.remove(&name);
528            } else {
529                self.emphasis.selected_solids.insert(name);
530            }
531        }
532        self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
533        self.dirty = true;
534    }
535
536    /// Select SEVERAL components at once: emphasis over the union of their
537    /// member solids (the interference window's row click highlights both
538    /// participants of a pair through this).
539    pub fn select_components(&mut self, component_ids: &[String]) {
540        let members: Vec<String> = self
541            .assembly_components
542            .iter()
543            .filter(|record| component_ids.iter().any(|id| id == &record.id))
544            .flat_map(|record| record.solids.iter().cloned())
545            .collect();
546        let json = serde_json::json!({ "selected": { "solids": members } }).to_string();
547        let _ = self.emphasis.apply_json(&json);
548        self.dirty = true;
549    }
550
551    // --- constraint reference selection (the ref-select reuse) --------------
552
553    /// Enter the modal reference picker for an ASSEMBLY CONSTRAINT's
554    /// `elements`-style field (same widget, different commit target — Finish
555    /// routes through [`Self::assembly_update_constraint_no_rerun`] instead of
556    /// feature params). No roll-to-before: constraints pick against the FULL
557    /// assembly.
558    pub fn begin_ref_select_for_constraint(
559        &mut self,
560        constraint_id: &str,
561        path: Vec<String>,
562        label: String,
563        filter: Vec<String>,
564        multiple: bool,
565        seed_names: Vec<String>,
566    ) {
567        let restore_index = self.history.rollback();
568        self.selection_filter = SelectionFilter::from_ref_filter(&filter);
569        self.ref_select = Some(RefSelectState {
570            feature_id: constraint_id.to_string(),
571            path,
572            label,
573            filter,
574            multiple,
575            names: seed_names,
576            restore_index,
577            target: RefSelectTarget::AssemblyConstraint,
578        });
579        self.sync_ref_select_emphasis();
580    }
581
582    /// Commit a finished constraint ref-select: read the constraint's params
583    /// from the session state, write the picked names at the field path, and
584    /// update (fold only — the caller's continuation reruns).
585    pub(crate) fn assembly_commit_constraint_refs(
586        &mut self,
587        constraint_id: &str,
588        path: &[String],
589        names: &[String],
590        multiple: bool,
591    ) {
592        let state = self.assembly_state_value();
593        let Some(entry) = state
594            .get("constraints")
595            .and_then(Value::as_array)
596            .and_then(|constraints| {
597                constraints.iter().find(|entry| {
598                    entry
599                        .get("inputParams")
600                        .and_then(|params| params.get("id"))
601                        .and_then(Value::as_str)
602                        == Some(constraint_id)
603                })
604            })
605        else {
606            self.push_notice(format!("unknown constraint '{constraint_id}'"));
607            return;
608        };
609        let mut params = entry
610            .get("inputParams")
611            .cloned()
612            .unwrap_or_else(|| serde_json::json!({}));
613        let value = if multiple {
614            Value::Array(names.iter().cloned().map(Value::String).collect())
615        } else {
616            Value::String(names.first().cloned().unwrap_or_default())
617        };
618        super::selection_ux::set_json_at(&mut params, path, value);
619        if let Err(error) = self.assembly_update_constraint_no_rerun(constraint_id, &params.to_string())
620        {
621            self.push_notice(format!("constraint update failed: {error}"));
622        }
623    }
624
625    /// Build the `{solidName}@x,y,z` COMPONENT-LOCAL vertex ref for a vertex
626    /// pick on a component member (lane-E contract: world pick transformed by
627    /// the owning component's inverse pose). `None` when the solid belongs to
628    /// no component (vertex refs only exist for constraint selection).
629    pub(crate) fn component_vertex_ref(
630        &self,
631        solid_name: &str,
632        world_position: [f64; 3],
633    ) -> Option<String> {
634        let record = self.assembly_components.iter().find(|record| {
635            record.solids.iter().any(|member| member == solid_name)
636        })?;
637        let local = rigid_inverse_point(&record.transform, world_position);
638        Some(format!(
639            "{solid_name}@{},{},{}",
640            local[0], local[1], local[2]
641        ))
642    }
643}
644
645// BREP private tests: 2e3068b0de683536