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