Skip to main content

brep_render/engine_state/
assembly_ops.rs

1use super::*;
2use serde_json::Value;
3
4// Assembly sessions are thread-local. The runner reads its session at the end of
5// every run and ships it (`pipeline::AssemblySync`); the main thread ADOPTS that
6// — the component projection, the solved constraint state, the statuses, the DOF
7// summary and the overlay rows — instead of executing the history a second time.
8// Componentless histories ship nothing and the projection is cleared.
9//
10// The ONE main-side replay left is `ensure_assembly_session`: a constraint
11// mutation auto-solves against the resident geometry of the thread that asks, and
12// the kernel's registry is thread-local, so the session has to exist HERE. That
13// is per USER constraint edit, not per run. Mutations then call the kernel ABI,
14// adopt the result with an undo checkpoint, and re-snapshot so the panels read
15// the mutation before the rerun's reply lands. Auto-solve reruns the display
16// history; otherwise manual Solve does.
17
18/// What a component-insert names: an EXISTING parts-library entry (skip the
19/// store read — just add an instance) or a NEW part payload to hand to
20/// `add_part_to_library` (which dedups by sourceKey+signature and returns the
21/// EFFECTIVE entry name the instance must reference).
22pub enum ComponentInsert<'a> {
23    Existing {
24        part_name: &'a str,
25    },
26    New {
27        name: &'a str,
28        source_key: &'a str,
29        source_signature: &'a str,
30        document_json: &'a str,
31    },
32}
33
34/// A STABLE content signature of a document: a sorted-key JSON walk hashed
35/// with the (fixed-key, cross-process-deterministic) std SipHash. The ONE
36/// signature fn, at the ENGINE altitude so app-side and engine-side writers
37/// share one copy: the app's insert flow (`panels::file`), the edit-in-place
38/// Finish (`panels::assembly_edit::refresh_library_entry`) and the
39/// update-components comparison (`panels::update_components`) all write/compare
40/// a parts-library `sourceSignature` with THIS function — a freshly inserted,
41/// unchanged part must always compare up-to-date, which a second copy would
42/// break the moment either drifted.
43/// Uses the kernel's shared sorted-key hash, also used for in-memory library
44/// fingerprints. Invalid JSON retains the raw-text hashing fallback.
45pub fn document_signature(doc_json: &str) -> String {
46    use std::hash::{Hash, Hasher};
47    let hash = match serde_json::from_str::<serde_json::Value>(doc_json) {
48        Ok(value) => brep_kernel::stable_json_hash(&value),
49        Err(_) => {
50            let mut hasher = std::collections::hash_map::DefaultHasher::new();
51            doc_json.hash(&mut hasher);
52            hasher.finish()
53        }
54    };
55    format!("{hash:016x}")
56}
57
58/// A nested occurrence's Fix/Float as the assembly solve reads it.
59///
60/// `top_params` is the top-level instance's `inputParams`, `rest` the
61/// occurrence's path inside it (`ACOMP2`, `ACOMP1:ACOMP2`) and `document` the
62/// definition that instance places. Only an occurrence the solve moves can be
63/// fixed or freed here: every instance above it must be flexible (a rigid
64/// sub-assembly moves as one body, its parts never move on their own). The
65/// state is the instance's own `flexibleFixed[rest]`, else the grounding of
66/// the definition that places it (an explicit `isFixed`, else the first
67/// component, else a Fixed constraint's target), as `assembly::flexible`
68/// decides it.
69pub fn nested_fixed_state(top_params: &Value, rest: &str, document: &Value) -> Result<bool, String> {
70    let acomp = |document: &'_ Value, id: &str| -> Option<Value> {
71        document["features"].as_array()?.iter().find(|f| {
72            matches!(f["type"].as_str(), Some("ACOMP" | "ASSEMBLY COMPONENT")) && f["inputParams"]["id"].as_str() == Some(id)
73        }).cloned()
74    };
75    let rigid = |name: &str| {
76        format!("'{name}' is a rigid sub-assembly: it moves as one body, so its parts are not fixed or freed here. Tick Flexible on it, or edit it as the Work part")
77    };
78    let segments: Vec<&str> = rest.split(':').collect();
79    let (last, above) = segments.split_last().ok_or("empty occurrence path")?;
80    let top_name = top_params["partName"].as_str().unwrap_or_default();
81    if top_params["flexible"] != Value::Bool(true) {
82        return Err(rigid(top_name));
83    }
84    let mut document = document;
85    for id in above {
86        let feature = acomp(document, id).ok_or_else(|| format!("component '{id}' is missing"))?;
87        let name = feature["inputParams"]["partName"].as_str().unwrap_or_default().to_string();
88        if feature["inputParams"]["flexible"] != Value::Bool(true) {
89            return Err(rigid(&name));
90        }
91        document = &document["partsLibrary"][&name]["document"];
92    }
93    acomp(document, last).ok_or_else(|| format!("component '{last}' is missing"))?;
94    if let Some(fixed) = top_params["flexibleFixed"][rest].as_bool() {
95        return Ok(fixed);
96    }
97    let components: Vec<&Value> = document["features"]
98        .as_array()
99        .into_iter()
100        .flatten()
101        .filter(|f| matches!(f["type"].as_str(), Some("ACOMP" | "ASSEMBLY COMPONENT")))
102        .collect();
103    let feature = components.iter().find(|f| f["inputParams"]["id"].as_str() == Some(last));
104    if let Some(fixed) = feature.and_then(|f| f["inputParams"]["isFixed"].as_bool()) {
105        return Ok(fixed);
106    }
107    if components.first().and_then(|f| f["inputParams"]["id"].as_str()) == Some(last) {
108        return Ok(true);
109    }
110    let fixed_by_constraint = document["assembly"]["constraints"].as_array().into_iter().flatten().any(|c| {
111        c["type"].as_str() == Some("fixed")
112            && c["inputParams"]["elements"].as_array().into_iter().flatten().filter_map(Value::as_str).any(|e| e.split(':').next() == Some(*last))
113    });
114    Ok(fixed_by_constraint)
115}
116
117/// Rigid inverse `[Rᵀ | −Rᵀ·t]` of a component pose — used to express a picked
118/// WORLD point in COMPONENT-LOCAL coordinates (the vertex-ref contract).
119fn rigid_inverse_point(transform: &brep_kernel::AffineTransform, world: [f64; 3]) -> [f64; 3] {
120    let m = &transform.elements;
121    let d = [world[0] - m[3], world[1] - m[7], world[2] - m[11]];
122    [
123        m[0] * d[0] + m[4] * d[1] + m[8] * d[2],
124        m[1] * d[0] + m[5] * d[1] + m[9] * d[2],
125        m[2] * d[0] + m[6] * d[1] + m[10] * d[2],
126    ]
127}
128
129impl EngineState {
130    // --- read surface ------------------------------------------------------
131
132    /// Whether the current document is an ASSEMBLY document: any ACOMP-typed
133    /// feature, or a present `assembly` constraint block. Componentless
134    /// documents skip every main-side sync (zero cost for modeling files).
135    pub fn history_has_assembly(&self) -> bool {
136        let has_acomp = (0..self.history.len()).any(|index| {
137            matches!(
138                self.history.feature_type(index).as_deref(),
139                Some("ACOMP") | Some("ASSEMBLY COMPONENT")
140            )
141        });
142        has_acomp
143            || self
144                .history
145                .assembly_block()
146                .map(|block| !block.is_null())
147                .unwrap_or(false)
148    }
149
150    /// The scene's component records (deterministic id order) as of the last
151    /// applied run — the Assembly Structure tree's projection source. A VIEW
152    /// over the scene, never an owning structure.
153    pub fn assembly_components(&self) -> &[crate::pipeline::ComponentSnapshot] {
154        &self.assembly_components
155    }
156
157    /// The assembly tail of the last applied run, or an empty stand-in for a
158    /// componentless document / a document that has not run yet.
159    fn assembly_sync(&self) -> std::borrow::Cow<'_, crate::pipeline::AssemblySync> {
160        match &self.assembly_sync {
161            Some(sync) => std::borrow::Cow::Borrowed(sync),
162            None => std::borrow::Cow::Owned(crate::pipeline::AssemblySync::default()),
163        }
164    }
165
166    /// The per-constraint status rows (`[{id, type, enabled, open, status,
167    /// message, satisfied, error}]`) the run reported.
168    pub fn assembly_statuses_value(&mut self) -> Value {
169        match &self.assembly_sync().statuses {
170            Value::Null => Value::Array(Vec::new()),
171            value => value.clone(),
172        }
173    }
174
175    /// The current `assembly` block (post-solve): `{constraints, idCounter}`.
176    pub fn assembly_state_value(&mut self) -> Value {
177        match &self.assembly_sync().state {
178            // Before the first run there is no state at all; answer with the
179            // empty block every caller expects rather than a bare null.
180            Value::Null => serde_json::json!({ "constraints": [], "idCounter": 0 }),
181            value => value.clone(),
182        }
183    }
184
185    /// The last solve's DOF/diagnostics summary (`{ok, dof, rank, redundant,
186    /// …}` — `movedSolids` may be absent; tolerate it).
187    pub fn assembly_dof_value(&mut self) -> Value {
188        self.assembly_sync().dof.clone()
189    }
190
191    /// Per-constraint overlay rows (world anchors/directions/status/value) —
192    /// the constraints panel reads the evaluated value/unit for the
193    /// distance/angle label suffix; lane G's viewport graphics read the rest.
194    pub fn assembly_overlay_value(&mut self) -> Value {
195        match &self.assembly_sync().overlay {
196            Value::Null => Value::Array(Vec::new()),
197            value => value.clone(),
198        }
199    }
200
201    /// The parts-library entry names currently resident (insert-flow "existing
202    /// entries first" list). The main-side store, seeded by document ingest and
203    /// kept current by the runs that heal or GC it (`apply_run_output` installs
204    /// the store a run moved), and grown by [`insert_component`](Self::insert_component).
205    pub fn parts_library_names(&mut self) -> Vec<String> {
206        serde_json::from_str::<Value>(&brep_kernel::parts_library_json())
207            .ok()
208            .and_then(|value| {
209                value
210                    .as_object()
211                    .map(|map| map.keys().cloned().collect::<Vec<_>>())
212            })
213            .unwrap_or_default()
214    }
215
216    // --- the adopt + fold (pose-authority contract) -------------------------
217
218    /// Adopt the assembly tail the runner shipped with the applied run: the
219    /// component projection, and the post-solve constraint state folded back
220    /// into the document (silent — a solver write-back is not a user edit; the
221    /// pose/`isFixed` half was already folded in `apply_run_output`, and the
222    /// parts-library store the run moved was installed there too).
223    /// Componentless documents just clear the projection.
224    ///
225    /// This is the call that used to be `sync_assembly`, a second full
226    /// `execute_history` on the UI thread. See `pipeline::AssemblySync`.
227    pub(crate) fn adopt_assembly_sync(&mut self) {
228        let Some(sync) = &self.assembly_sync else {
229            self.assembly_components.clear();
230            // No ACOMP references left ⇒ any partsLibrary block is orphaned
231            // payload (the kernel GCs the store the same way at the end of
232            // every run) — drop it so deleting the last instance never leaves
233            // a dangling entry in the saved document. Gated on the WHOLE
234            // history, not on the run: the run executes the rolled-to PREFIX,
235            // so rolling back behind the first ACOMP ships no tail while the
236            // document's components — and therefore its library — are still
237            // very much there.
238            if !self.history_has_assembly() {
239                self.history
240                    .set_parts_library(Value::Object(serde_json::Map::new()));
241            }
242            return;
243        };
244        self.assembly_components = sync.components.clone();
245        let state = sync.state.clone();
246        self.fold_assembly_state(state, false);
247    }
248
249    /// Write the post-solve `assembly` block into the document — the state half
250    /// of the pose-authority write-back, which the kernel's
251    /// `assembly_apply_document_json` performs alongside the pose half.
252    /// `checkpoint` = true for a USER mutation (undoable), false for the silent
253    /// post-run adopt. A null state (no session / no constraints yet) writes
254    /// nothing.
255    fn fold_assembly_state(&mut self, state: Value, checkpoint: bool) {
256        if state.is_null() {
257            return;
258        }
259        if self.history.assembly_block() == Some(&state) {
260            return;
261        }
262        self.history.set_assembly_block(state, checkpoint);
263    }
264
265    /// Make sure a main-side kernel assembly SESSION exists for the last applied
266    /// run — the one thing the runner's reply cannot carry.
267    ///
268    /// A constraint mutation auto-solves immediately, against the resident
269    /// geometry of whichever thread holds it, and the kernel's registry and
270    /// session are both thread-local; so a mutation needs the history executed
271    /// HERE. That is per USER constraint edit — adding, editing, enabling,
272    /// reordering or removing a mate, inferring constraints, or pressing Solve —
273    /// not per run, which is the whole difference from the old `sync_assembly`.
274    /// Cheap no-op when the session is already current, and for a componentless
275    /// document (nothing to solve).
276    ///
277    /// The runner's own session comes first: an owned copy of it
278    /// ([`crate::runner::HistoryRunner::session_for_main`]), imported when it is
279    /// the session of the run applied here, and only while no newer run is in
280    /// flight. Otherwise, and on any refusal, the history is replayed here as
281    /// before.
282    pub(crate) fn ensure_assembly_session(&mut self) {
283        if self.assembly_session_generation == Some(self.applied_generation)
284            && self.assembly_session_serial == brep_kernel::assembly_session_serial()
285        {
286            return;
287        }
288        if !self.history_has_assembly() {
289            return;
290        }
291        if self.session_transfer && !self.run_pending() {
292            let mut _trace = crate::run_trace::span("assembly_session_import");
293            match self.runner.session_for_main(self.applied_generation) {
294                crate::runner::SessionOnMain::Resident => {
295                    self.session_imports += 1;
296                    self.mark_session_current();
297                    return;
298                }
299                crate::runner::SessionOnMain::Copied(transfer) => {
300                    let retired = brep_kernel::assembly_session_import(transfer);
301                    // Dropping the replaced copy costs milliseconds: not here.
302                    #[cfg(not(target_arch = "wasm32"))]
303                    std::thread::spawn(move || drop(retired));
304                    #[cfg(target_arch = "wasm32")]
305                    drop(retired);
306                    self.session_imports += 1;
307                    self.mark_session_current();
308                    return;
309                }
310                crate::runner::SessionOnMain::Unavailable => {}
311            }
312        }
313        self.session_replays += 1;
314        let request: brep_kernel::HistoryRequest =
315            match serde_json::from_value(self.run_request_value()) {
316                Ok(request) => request,
317                Err(_) => return, // unparseable mid-edit document — retry next frame
318            };
319        let mut _trace = crate::run_trace::span("assembly_session");
320        let result = self.execute_plugin_history(&request);
321        if let Some(trace) = _trace.as_mut() {
322            trace.result(&result);
323        }
324        self.mark_session_current();
325    }
326
327    fn mark_session_current(&mut self) {
328        self.assembly_session_generation = Some(self.applied_generation);
329        self.assembly_session_serial = brep_kernel::assembly_session_serial();
330    }
331
332    /// Drop the main-side session's currency stamp, so the next
333    /// `Self::ensure_assembly_session` replays this document. The kernel
334    /// session is one per thread, shared by every open document: a caller that
335    /// may have let another document replay (or changed the kernel's parts
336    /// library) since this one's last replay calls this first.
337    pub fn forget_assembly_session(&mut self) {
338        self.assembly_session_generation = None;
339    }
340
341    /// Re-read the main-side session into [`Self::assembly_sync`] after a
342    /// MUTATION solved against it, so the panels (which read the snapshot, not
343    /// the kernel) show the mutation immediately instead of lagging until the
344    /// rerun's reply lands. The component projection is unchanged by a
345    /// constraint mutation — only the solve moves poses, and the rerun brings
346    /// those — so it is carried over.
347    pub(crate) fn snapshot_assembly_from_session(&mut self) {
348        let parse = |json: String| serde_json::from_str::<Value>(&json).unwrap_or(Value::Null);
349        let components = self.assembly_components.clone();
350        self.assembly_sync = Some(crate::pipeline::AssemblySync {
351            components,
352            state: parse(brep_kernel::assembly_state_json()),
353            statuses: parse(brep_kernel::assembly_statuses_json()),
354            dof: parse(brep_kernel::assembly_dof_json()),
355            overlay: parse(brep_kernel::assembly_overlay_json()),
356            parts_library: None,
357        });
358    }
359
360    /// Run the document fold: `assembly_apply_document_json(document)` →
361    /// adopt the returned document (assembly block replaced; solved poses +
362    /// isFixed folded into features by `inputParams.id`). `checkpoint` = true
363    /// for USER mutations (undoable), false for the silent post-run fold.
364    /// A missing session (no run yet) is tolerated silently.
365    fn apply_assembly_fold(&mut self, checkpoint: bool) {
366        // The fold only rewrites the `assembly` block and per-feature
367        // `inputParams` — it never reads `partsLibrary` — so hand it the
368        // document WITHOUT the library. Otherwise every edit serialized,
369        // parsed and re-serialized the whole embedded part payload three times
370        // over for nothing. `adopt_document` keeps the field when the adopted
371        // document carries no block, so the library survives the round trip.
372        let document = self.history.request_json_without_parts_library();
373        match brep_kernel::assembly_apply_document_impl(&document) {
374            Ok(folded) => {
375                let adopted = if checkpoint {
376                    self.history.adopt_document_checkpointed(&folded)
377                } else {
378                    self.history.adopt_document(&folded)
379                };
380                if let Err(error) = adopted {
381                    self.push_notice(format!("assembly fold failed: {error}"));
382                }
383                // The panels read the SHIPPED snapshot, not the kernel, so a
384                // mutation that solved against the live session has to refresh
385                // it — otherwise the constraint the user just added shows its
386                // pre-solve status until the rerun's reply lands a frame or more
387                // later. ONLY on the Ok arm: with no session the kernel's reads
388                // answer with defaults, which would blank a good snapshot.
389                self.snapshot_assembly_from_session();
390            }
391            Err(_) => {} // no session yet (fresh document before its first run)
392        }
393    }
394
395    /// Shared tail of every USER constraint mutation: fold (checkpointed) and,
396    /// when auto-solve is on, re-run the display history so the viewport
397    /// re-poses (changed ACOMP transforms dirty their fingerprints → those
398    /// instances re-execute + re-tessellate; the runner's tail re-solves).
399    ///
400    /// `pub(super)` because a constraint mutation also arrives from the VIEWPORT:
401    /// an overlay handle drag commits on release
402    /// ([`EngineState::constraint_drag_release`]) and needs the same two things
403    /// this gives every panel edit — an undo entry for the drag, and a rerun
404    /// that re-keys the moved instance's cache entry to its new pose.
405    pub(super) fn after_constraint_mutation(&mut self) {
406        self.apply_assembly_fold(true);
407        if self.settings.assembly_auto_solve {
408            self.rerun_history();
409        } else {
410            self.dirty = true;
411        }
412    }
413
414    // --- constraint mutations (kernel ABI + fold + optional rerun) ----------
415
416    /// Add a constraint (`params_json` = inputParams; the kernel mints the id
417    /// from the type's short name when absent). Returns the minted id.
418    pub fn assembly_add_constraint(
419        &mut self,
420        constraint_type: &str,
421        params_json: &str,
422    ) -> Result<String, String> {
423        self.ensure_assembly_session();
424        let reply =
425            brep_kernel::assembly_add_constraint_impl(constraint_type, params_json)?;
426        self.after_constraint_mutation();
427        let id = serde_json::from_str::<Value>(&reply)
428            .ok()
429            .and_then(|value| value.get("id").and_then(|id| id.as_str()).map(String::from))
430            .unwrap_or_default();
431        Ok(id)
432    }
433
434    /// Replace a constraint's `inputParams` (the dialog commit).
435    pub fn assembly_update_constraint(&mut self, id: &str, params_json: &str) -> Result<(), String> {
436        self.ensure_assembly_session();
437        brep_kernel::assembly_update_constraint_impl(id, params_json)?;
438        self.after_constraint_mutation();
439        Ok(())
440    }
441
442    /// Update WITHOUT the rerun tail — for callers whose own continuation
443    /// re-runs anyway (the ref-select Finish, whose `end_ref_select` reruns).
444    /// Still folds (checkpointed) so the document is current before that run.
445    pub(crate) fn assembly_update_constraint_no_rerun(
446        &mut self,
447        id: &str,
448        params_json: &str,
449    ) -> Result<(), String> {
450        self.ensure_assembly_session();
451        brep_kernel::assembly_update_constraint_impl(id, params_json)?;
452        self.apply_assembly_fold(true);
453        Ok(())
454    }
455
456    /// Delete a constraint.
457    pub fn assembly_remove_constraint(&mut self, id: &str) -> Result<(), String> {
458        self.ensure_assembly_session();
459        brep_kernel::assembly_remove_constraint_impl(id)?;
460        self.after_constraint_mutation();
461        Ok(())
462    }
463
464    /// Enable/disable a constraint (the row checkbox).
465    pub fn assembly_set_constraint_enabled(&mut self, id: &str, enabled: bool) -> Result<(), String> {
466        self.ensure_assembly_session();
467        brep_kernel::assembly_set_constraint_enabled_impl(id, enabled)?;
468        self.after_constraint_mutation();
469        Ok(())
470    }
471
472    /// Persist WHICH constraint has its dialog open (view state — SILENT fold,
473    /// no solve, no rerun, no undo entry). An open COUNTS
474    /// ([`Self::assembly_constraint_opens`]) — including a re-open of the
475    /// constraint already open, which is what the dialog door needs to surface
476    /// the pane for a label click on a form the user cannot see. ACCORDION: at
477    /// most ONE constraint is open — opening one first closes every other inside the same silent fold,
478    /// so the panel toggle, the context bar's add-from-selection, and a viewport
479    /// label click all converge on a single open constraint. The constraints
480    /// PANEL reads this flag as its mode: open one and its form replaces the
481    /// tree (`panels::assembly_constraints`), which is why every one of those
482    /// surfaces opens the dialog without knowing the panel exists.
483    pub fn assembly_set_constraint_open(&mut self, id: &str, open: bool) -> Result<(), String> {
484        self.ensure_assembly_session();
485        if open {
486            let others: Vec<String> =
487                serde_json::from_str::<Value>(&brep_kernel::assembly_state_json())
488                    .ok()
489                    .and_then(|state| {
490                        state.get("constraints").and_then(|l| l.as_array()).map(|entries| {
491                            entries
492                                .iter()
493                                .filter_map(|entry| {
494                                    let cid =
495                                        entry.get("inputParams")?.get("id")?.as_str()?;
496                                    let is_open =
497                                        entry.get("open").and_then(|v| v.as_bool()).unwrap_or(false);
498                                    (is_open && cid != id).then(|| cid.to_string())
499                                })
500                                .collect()
501                        })
502                    })
503                    .unwrap_or_default();
504            for other in others {
505                brep_kernel::assembly_set_constraint_open_impl(&other, false)?;
506            }
507        }
508        brep_kernel::assembly_set_constraint_open_impl(id, open)?;
509        if open {
510            self.constraint_opens = self.constraint_opens.wrapping_add(1);
511        }
512        self.apply_assembly_fold(false);
513        Ok(())
514    }
515
516    /// How many constraint dialogs this engine has OPENED through
517    /// [`Self::assembly_set_constraint_open`] — the app's dialog door reads it
518    /// beside [`Self::assembly_open_constraint`] so a re-open of the constraint
519    /// already open is still an open (the id alone does not move: a viewport
520    /// label click on the constraint whose form is up, made precisely because
521    /// the user is on another tab).
522    ///
523    /// It counts the OPENS, not the flag's history. The flag itself lives in
524    /// the kernel and a freshly minted constraint arrives `open: true` without
525    /// passing through the setter — that one needs no count, because a new
526    /// constraint brings a new id and the door's id half already sees it.
527    pub fn assembly_constraint_opens(&self) -> u64 {
528        self.constraint_opens
529    }
530
531    /// The id of the constraint whose DIALOG is open, or `None`.
532    ///
533    /// The accordion keeps at most one (`assembly_set_constraint_open` closes
534    /// every other), so this is the panel's mode as ONE value. Reads the same
535    /// two fields the Constraints panel's row builder reads — `inputParams.id`
536    /// and `open` — off the borrowed fold state, without cloning the whole
537    /// assembly block the way `assembly_state_value` does: the app's dialog
538    /// door calls this every frame.
539    pub fn assembly_open_constraint(&self) -> Option<String> {
540        self.assembly_sync()
541            .state
542            .get("constraints")?
543            .as_array()?
544            .iter()
545            .find(|entry| entry.get("open").and_then(Value::as_bool).unwrap_or(false))
546            .and_then(|entry| entry.get("inputParams")?.get("id")?.as_str())
547            .map(str::to_string)
548    }
549
550    /// Reorder a constraint to `index` (drag-reorder).
551    pub fn assembly_move_constraint(&mut self, id: &str, index: usize) -> Result<(), String> {
552        self.ensure_assembly_session();
553        brep_kernel::assembly_move_constraint_impl(id, index)?;
554        self.after_constraint_mutation();
555        Ok(())
556    }
557
558    // --- automatic inference (the Auto Constraints button) ------------------
559
560    /// The constraint types the inference lane can produce — `[{type, label,
561    /// icon, longName, detects, defaultOn}]`, straight off the kernel's rule
562    /// table. The dialog lists THIS; it keeps no roster of its own, so a rule
563    /// added in the kernel shows up as a row without an app change.
564    pub fn assembly_inferable_types(&self) -> Value {
565        serde_json::from_str(&brep_kernel::assembly_inferable_types_json())
566            .unwrap_or_else(|_| Value::Array(Vec::new()))
567    }
568
569    /// SCAN the placed components for the constraints their placement implies,
570    /// creating nothing. `options` is the kernel's `InferOptions` JSON (`{}` for
571    /// the defaults; the dialog sends the ticked `types`).
572    pub fn assembly_infer_constraints(&mut self, options: &str) -> Value {
573        self.ensure_assembly_session();
574        serde_json::from_str(&brep_kernel::assembly_infer_constraints_json(options))
575            .unwrap_or(Value::Null)
576    }
577
578    /// Scan and CREATE, as one undoable step: the kernel adds every accepted
579    /// candidate and solves the batch once, then the usual mutation tail folds
580    /// the result into the document and re-runs the display. A scan that found
581    /// nothing skips the tail entirely (no checkpoint, no rerun).
582    pub fn assembly_apply_inferred_constraints(&mut self, options: &str) -> Value {
583        self.ensure_assembly_session();
584        let reply: Value =
585            serde_json::from_str(&brep_kernel::assembly_apply_inferred_constraints_json(options))
586                .unwrap_or(Value::Null);
587        let created = reply
588            .get("created")
589            .and_then(|value| value.as_array())
590            .map(|list| list.len())
591            .unwrap_or(0);
592        if created > 0 {
593            self.after_constraint_mutation();
594        }
595        reply
596    }
597
598    /// Manual solve (the panel's Solve button): solve the session, fold, and
599    /// ALWAYS re-run the display (that is the point of pressing Solve — it
600    /// works with auto-solve disabled).
601    pub fn assembly_run_solve(&mut self) -> Result<(), String> {
602        self.ensure_assembly_session();
603        // Nothing to solve without components — bail BEFORE the kernel solve:
604        // an empty assembly solving to a no-op is the correct result. (This
605        // bail was once also the only thing between a missing session and a
606        // native abort; the `_impl` doors now return that error as text.)
607        if self.assembly_components().is_empty() {
608            return Ok(());
609        }
610        brep_kernel::assembly_run_solve_impl()?;
611        self.apply_assembly_fold(true);
612        self.rerun_history();
613        Ok(())
614    }
615
616    // --- component actions (route to the owning ACOMP feature) --------------
617
618    /// Insert a component instance (the palette/insert flow): resolve the
619    /// parts-library entry (add or reuse), refresh the document's
620    /// `partsLibrary` block, append an ACOMP feature referencing the RETURNED
621    /// effective part name with an identity transform, and re-run. The FIRST
622    /// component of the document writes `isFixed: true` EXPLICITLY (dialog-
623    /// visible); later instances write `false`. Returns the new feature id
624    /// (`ACOMP<digits>` — the history's global counter mints that exact form).
625    pub fn insert_component(&mut self, insert: ComponentInsert<'_>) -> Result<String, String> {
626        let part_name = self.resolve_component_insert(insert)?;
627        let first = !(0..self.history.len()).any(|index| {
628            matches!(
629                self.history.feature_type(index).as_deref(),
630                Some("ACOMP") | Some("ASSEMBLY COMPONENT")
631            )
632        });
633        let id = self.history.next_feature_id("ACOMP");
634        let feature = serde_json::json!({
635            "type": "ACOMP",
636            "inputParams": {
637                "id": id,
638                "partName": part_name,
639                "transform": { "translate": [0, 0, 0], "rotateEulerDeg": [0, 0, 0] },
640                "isFixed": first,
641            },
642            "persistentData": {}
643        });
644        self.add_feature(&feature.to_string())?;
645        Ok(id)
646    }
647
648    /// The parts-library entry an insert (or a replace) names: an existing
649    /// entry as is, or a new payload added (deduplicated by
650    /// sourceKey+signature) — with the document's `partsLibrary` block
651    /// refreshed so the next run ingests it. Returns the EFFECTIVE entry name.
652    pub(crate) fn resolve_component_insert(&mut self, insert: ComponentInsert<'_>) -> Result<String, String> {
653        let part_name = match insert {
654            ComponentInsert::Existing { part_name } => part_name.to_string(),
655            ComponentInsert::New {
656                name,
657                source_key,
658                source_signature,
659                document_json,
660            } => match brep_kernel::add_part_to_library_impl(
661                name,
662                source_key,
663                source_signature,
664                document_json,
665            ) {
666                Ok(part_name) => part_name,
667                Err(error) => {
668                    // The Insert component dialog closes on Insert, so its
669                    // status line never shows this; the toast is what the user
670                    // sees (a file that is not a part document, for one).
671                    self.push_notice(format!("Could not insert '{name}': {error}"));
672                    return Err(error);
673                }
674            },
675        };
676        // The block must ride the request so the display runner ingests the
677        // (new) entry on the very next run.
678        if let Ok(library) = serde_json::from_str::<Value>(&brep_kernel::parts_library_json()) {
679            self.history.set_parts_library(library);
680        }
681        Ok(part_name)
682    }
683
684    /// Fix/Unfix a component: toggles the OWNING ACOMP feature's
685    /// `inputParams.isFixed` (one truth, one undo lane) and re-runs. A nested
686    /// occurrence (`ACOMP1:ACOMP2`) is fixed or freed on the top instance that
687    /// lends it (`inputParams.flexibleFixed`, by instance-local path), never in
688    /// the sub-assembly's document; [`nested_fixed_state`] says when that is
689    /// not possible.
690    pub fn set_component_fixed(&mut self, component_id: &str, fixed: bool) -> Result<(), String> {
691        if let Some((top, rest)) = component_id.split_once(':') {
692            self.occurrence_fixed(component_id)?;
693            let index = self.history.index_of(top).ok_or_else(|| format!("no component feature '{top}'"))?;
694            let mut params = self.history.feature_params(index).unwrap_or_else(|| serde_json::json!({}));
695            let Some(map) = params.as_object_mut() else {
696                return Err(format!("component '{top}': malformed inputParams"));
697            };
698            let overrides = map.entry("flexibleFixed").or_insert_with(|| serde_json::json!({}));
699            if !overrides.is_object() {
700                *overrides = serde_json::json!({});
701            }
702            overrides[rest] = Value::Bool(fixed);
703            self.update_feature_params(top, &params.to_string())?;
704            return Ok(());
705        }
706        let index = self
707            .history
708            .index_of(component_id)
709            .ok_or_else(|| format!("no component feature '{component_id}'"))?;
710        let mut params = self
711            .history
712            .feature_params(index)
713            .unwrap_or_else(|| serde_json::json!({}));
714        if let Some(map) = params.as_object_mut() {
715            map.insert("isFixed".into(), Value::Bool(fixed));
716        } else {
717            return Err(format!("component '{component_id}': malformed inputParams"));
718        }
719        self.update_feature_params(component_id, &params.to_string())?;
720        Ok(())
721    }
722
723    /// Whether a nested occurrence (`ACOMP1:ACOMP2`) is fixed, or why it
724    /// cannot be fixed or freed from this assembly ([`nested_fixed_state`]).
725    pub fn occurrence_fixed(&self, path: &str) -> Result<bool, String> {
726        let (top, rest) = path.split_once(':').ok_or_else(|| format!("'{path}' is not a nested occurrence"))?;
727        let index = self.history.index_of(top).ok_or_else(|| format!("no component feature '{top}'"))?;
728        let params = self.history.feature_params(index).unwrap_or_default();
729        let part = params["partName"].as_str().unwrap_or_default();
730        nested_fixed_state(&params, rest, &self.history.parts_library()[part]["document"])
731    }
732
733    /// Select a component in the viewport: emphasis over exactly its member
734    /// solids (the tree↔viewport sync lane; a viewport pick of any member
735    /// lights the tree row through the same emphasis set).
736    pub fn select_component(&mut self, component_id: &str) {
737        self.select_components(&[component_id.to_string()]);
738    }
739
740    /// A component's member SOLID scene names (empty for an unknown id) — the
741    /// selection/hover unit COMPONENT entries resolve to.
742    pub fn component_member_solids(&self, component_id: &str) -> Vec<String> {
743        self.assembly_components
744            .iter()
745            .filter(|record| record.id == component_id)
746            .flat_map(|record| record.solids.iter().cloned())
747            .collect()
748    }
749
750    /// TOGGLE a component in the current selection as ONE unit (the additive
751    /// Ctrl/Cmd+click under COMPONENT promotion): when EVERY member solid is
752    /// already selected the whole set deselects, otherwise the whole set joins
753    /// the selection — the rest of the selection stays.
754    pub fn toggle_component_selection(&mut self, component_id: &str) {
755        let members = self.component_member_solids(component_id);
756        if members.is_empty() {
757            return;
758        }
759        let all_selected = members
760            .iter()
761            .all(|name| self.emphasis.selected_solids.contains(name));
762        for name in members {
763            if all_selected {
764                self.emphasis.selected_solids.remove(&name);
765            } else {
766                self.emphasis.selected_solids.insert(name);
767            }
768        }
769        self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
770        self.dirty = true;
771    }
772
773    /// Select SEVERAL components at once: emphasis over the union of their
774    /// member solids (the interference window's row click highlights both
775    /// participants of a pair through this).
776    pub fn select_components(&mut self, component_ids: &[String]) {
777        // An id may be a nested occurrence (`ACOMP2:ACOMP1`): the solids of
778        // its top component under that path.
779        let members: Vec<String> = self
780            .assembly_components
781            .iter()
782            .flat_map(|record| {
783                record.solids.iter().filter(move |solid| {
784                    component_ids.iter().any(|id| {
785                        id == &record.id
786                            || (id.split(':').next() == Some(record.id.as_str()) && solid.starts_with(&format!("{id}:")))
787                    })
788                })
789            })
790            .cloned()
791            .collect();
792        let json = serde_json::json!({ "selected": { "solids": members } }).to_string();
793        let _ = self.emphasis.apply_json(&json);
794        self.dirty = true;
795    }
796
797    // --- constraint reference selection (the ref-select reuse) --------------
798
799    /// Enter the modal reference picker for an ASSEMBLY CONSTRAINT's
800    /// `elements`-style field (same widget, different commit target — Finish
801    /// routes through `Self::assembly_update_constraint_no_rerun` instead of
802    /// feature params). No roll-to-before: constraints pick against the FULL
803    /// assembly.
804    pub fn begin_ref_select_for_constraint(
805        &mut self,
806        constraint_id: &str,
807        path: Vec<String>,
808        label: String,
809        filter: Vec<String>,
810        multiple: bool,
811        seed_names: Vec<String>,
812    ) {
813        let restore_index = self.history.rollback();
814        self.selection_filter = SelectionFilter::from_ref_filter(&filter);
815        self.ref_select = Some(RefSelectState {
816            feature_id: constraint_id.to_string(),
817            path,
818            label,
819            filter,
820            multiple,
821            names: seed_names,
822            restore_index,
823            target: RefSelectTarget::AssemblyConstraint,
824            picks: 0,
825            claimed: false,
826            finish_requested: false,
827        });
828        self.sync_ref_select_emphasis();
829    }
830
831    /// Commit a finished constraint ref-select: read the constraint's params
832    /// from the session state, write the picked names at the field path, and
833    /// update (fold only — the caller's continuation reruns).
834    pub(crate) fn assembly_commit_constraint_refs(
835        &mut self,
836        constraint_id: &str,
837        path: &[String],
838        names: &[String],
839        multiple: bool,
840    ) {
841        let state = self.assembly_state_value();
842        let Some(entry) = state
843            .get("constraints")
844            .and_then(Value::as_array)
845            .and_then(|constraints| {
846                constraints.iter().find(|entry| {
847                    entry
848                        .get("inputParams")
849                        .and_then(|params| params.get("id"))
850                        .and_then(Value::as_str)
851                        == Some(constraint_id)
852                })
853            })
854        else {
855            self.push_notice(format!("unknown constraint '{constraint_id}'"));
856            return;
857        };
858        let mut params = entry
859            .get("inputParams")
860            .cloned()
861            .unwrap_or_else(|| serde_json::json!({}));
862        let value = if multiple {
863            Value::Array(names.iter().cloned().map(Value::String).collect())
864        } else {
865            Value::String(names.first().cloned().unwrap_or_default())
866        };
867        super::selection_ux::set_json_at(&mut params, path, value);
868        if let Err(error) = self.assembly_update_constraint_no_rerun(constraint_id, &params.to_string())
869        {
870            self.push_notice(format!("constraint update failed: {error}"));
871        }
872    }
873
874    /// Build the `{solidName}@x,y,z` COMPONENT-LOCAL vertex ref for a vertex
875    /// pick on a component member (lane-E contract: world pick transformed by
876    /// the owning component's inverse pose). `None` when the solid belongs to
877    /// no component (vertex refs only exist for constraint selection).
878    pub(crate) fn component_vertex_ref(
879        &self,
880        solid_name: &str,
881        world_position: [f64; 3],
882    ) -> Option<String> {
883        let record = self.assembly_components.iter().find(|record| {
884            record.solids.iter().any(|member| member == solid_name)
885        })?;
886        let local = rigid_inverse_point(&record.affine(), world_position);
887        Some(format!(
888            "{solid_name}@{},{},{}",
889            local[0], local[1], local[2]
890        ))
891    }
892}
893