Skip to main content

brep_kernel/feature_pipeline/
assembly.rs

1//! Assembly constraint state, lifecycle, and solver mapping.
2//!
3//! # The `assembly` block
4//!
5//! Constraints are kernel history state: a top-level `assembly` block on the
6//! history request — `{ constraints: [ { type, inputParams, persistentData,
7//! enabled, open } ], idCounter }` — that round-trips through the saved
8//! document. The pre-purge envelope keys (`assemblyConstraints` /
9//! `assemblyConstraintIdCounter`) are NOT read (no-backwards-compat).
10//!
11//! # Lifecycle, run at the tail of every history execution
12//!
13//! 1. VALIDATE ([`lifecycle`]): disabled → `status:'disabled'`; unknown type →
14//!    an error entry without aborting the rest; duplicate detection across the
15//!    overlapping family {touch_align, distance, angle, concentric,
16//!    perpendicular, tangent} via order-independent selection-pair signatures.
17//! 2. MAP ([`mapping`]): each element ref resolves through
18//!    `solvers/assembly_resolve` to an analytic frame in the owning component's
19//!    LOCAL space. Unresolvable/unsupported selections fail that constraint
20//!    only (status from [`crate::ResolveError::status`]); the solve continues.
21//! 3. SOLVE: ONE [`crate::solve_assembly`] call over every mapped constraint;
22//!    per-mate residuals/statuses + solver diagnostics merge into each
23//!    constraint's `persistentData`; distance/angle first-solve initialization
24//!    commits into `inputParams` only on success.
25//! 4. WRITE BACK: solved poses re-pose the scene components in place
26//!    ([`crate::feature_pipeline::component`]) and are exported as generic
27//!    `inputParams.transform` JSON updates for the owning ACOMP features (the
28//!    pose-authority contract; applied by [`assembly_apply_document_json`] with
29//!    no compile-time dependency on the ACOMP feature).
30//!
31//! # Pose self-consistency (why an unapplied write-back cannot corrupt)
32//!
33//! Mate-local frames are computed as `record.transform⁻¹ · world_frame`, and
34//! each body's solver pose IS `record.transform` — so even when the record's
35//! absolute pose is stale (the app has not yet folded a pose update back into
36//! the ACOMP feature), the solve is self-consistent: reconstructed world frames
37//! equal the actual world geometry, satisfied constraints produce no motion,
38//! and a re-executed component simply re-solves to the same poses. The
39//! write-back matters for PERSISTENCE (save/load) and cache fingerprints, not
40//! for per-run geometric correctness.
41
42use serde::{Deserialize, Serialize};
43
44use crate::feature_pipeline::Env;
45
46pub(crate) mod exports;
47pub(crate) mod infer;
48pub(crate) mod lifecycle;
49pub(crate) mod mapping;
50pub(crate) mod constraints;
51
52pub use exports::{
53    assembly_add_constraint_json, assembly_apply_document_json,
54    assembly_apply_inferred_constraints_json, assembly_dof_json,
55    assembly_infer_constraints_json, assembly_inferable_types_json,
56    assembly_move_constraint_json, assembly_overlay_json, assembly_pose_updates_json,
57    assembly_remove_constraint_json, assembly_run_solve_json,
58    assembly_set_constraint_enabled_json, assembly_set_constraint_open_json,
59    assembly_state_json, assembly_statuses_json, assembly_update_constraint_json,
60    // The native doors: the same mutations with their errors as text. A native
61    // caller must use these — a `JsValue` error aborts a native build.
62    assembly_add_constraint_impl, assembly_apply_document_impl, assembly_move_constraint_impl,
63    assembly_remove_constraint_impl, assembly_run_solve_impl,
64    assembly_set_constraint_enabled_impl, assembly_set_constraint_open_impl,
65    assembly_update_constraint_impl,
66};
67pub use constraints::{
68    constraint_schema_catalogue, constraint_type, ConstraintTypeDef, CONSTRAINT_TYPES,
69};
70// The pose write-back encoder (`mapping::transform_to_pose_params`): the ONE
71// matrix → `{translate, rotateEulerDeg}` (intrinsic XYZ, degrees) conversion,
72// re-exported so out-of-crate ACOMP authors reuse it instead of re-deriving
73// the Euler decomposition (and its gimbal-lock branch).
74pub use mapping::transform_to_pose_params;
75
76// ===========================================================================
77// ===========================================================================
78
79/// The document's `assembly` block: the ordered constraint list plus the
80/// persistent id counter (monotonic, never reused — mirrors the feature
81/// counter's contract).
82#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
83pub struct AssemblyState {
84    #[serde(default)]
85    pub constraints: Vec<ConstraintEntry>,
86    #[serde(default, rename = "idCounter")]
87    pub id_counter: u64,
88}
89
90/// One persisted constraint: `{ type, inputParams, persistentData, enabled,
91/// open }`. `inputParams` carries `id`, `elements` (selection ref strings) and
92/// the type-specific params; `persistentData` is the solver-state merge target
93/// (status/message/diagnostics/orientation cache).
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95pub struct ConstraintEntry {
96    #[serde(rename = "type")]
97    pub constraint_type: String,
98    #[serde(rename = "inputParams", default = "empty_object")]
99    pub input_params: serde_json::Value,
100    #[serde(rename = "persistentData", default = "empty_object")]
101    pub persistent_data: serde_json::Value,
102    #[serde(default = "default_true")]
103    pub enabled: bool,
104    #[serde(default)]
105    pub open: bool,
106}
107
108fn empty_object() -> serde_json::Value {
109    serde_json::Value::Object(serde_json::Map::new())
110}
111
112fn default_true() -> bool {
113    true
114}
115
116impl ConstraintEntry {
117    /// The constraint id (`inputParams.id`), empty when unset.
118    pub fn id(&self) -> &str {
119        self.input_params
120            .get("id")
121            .and_then(|value| value.as_str())
122            .unwrap_or("")
123    }
124
125    /// The selection refs (`inputParams.elements`), string entries only.
126    pub fn elements(&self) -> Vec<String> {
127        self.input_params
128            .get("elements")
129            .and_then(|value| value.as_array())
130            .map(|items| {
131                items
132                    .iter()
133                    .filter_map(|item| item.as_str())
134                    .map(str::to_string)
135                    .collect()
136            })
137            .unwrap_or_default()
138    }
139
140    /// A boolean param (absent → false).
141    pub fn flag(&self, key: &str) -> bool {
142        self.input_params
143            .get(key)
144            .and_then(|value| value.as_bool())
145            .unwrap_or(false)
146    }
147
148    /// A numeric param, expression-capable (a string evaluates against the
149    /// part's expression scope). Absent → `default`.
150    pub fn number(&self, key: &str, env: &Env, default: f64) -> Result<f64, String> {
151        match self.input_params.get(key) {
152            None | Some(serde_json::Value::Null) => Ok(default),
153            Some(serde_json::Value::Number(number)) => number
154                .as_f64()
155                .ok_or_else(|| format!("param `{key}` is not a finite number")),
156            Some(serde_json::Value::String(source)) => env
157                .eval(source)
158                .map_err(|error| format!("param `{key}`: {error}")),
159            Some(other) => Err(format!(
160                "param `{key}` must be a number or expression string, found {other}"
161            )),
162        }
163    }
164
165    /// Merge a key into `persistentData` (created as an object when missing).
166    pub fn set_persistent(&mut self, key: &str, value: serde_json::Value) {
167        if !self.persistent_data.is_object() {
168            self.persistent_data = empty_object();
169        }
170        if let Some(map) = self.persistent_data.as_object_mut() {
171            map.insert(key.to_string(), value);
172        }
173    }
174
175    /// Remove a key from `persistentData` (no-op when absent).
176    pub fn remove_persistent(&mut self, key: &str) {
177        if let Some(map) = self.persistent_data.as_object_mut() {
178            map.remove(key);
179        }
180    }
181
182    /// A `persistentData` field, if present.
183    pub fn persistent(&self, key: &str) -> Option<&serde_json::Value> {
184        self.persistent_data.get(key)
185    }
186
187    /// Set the run status vocabulary trio (`status`, `message`, `satisfied`)
188    /// and clear stale duplicate bookkeeping unless this IS a duplicate mark.
189    pub fn set_status(&mut self, status: &str, message: impl Into<String>, satisfied: bool) {
190        self.set_persistent("status", serde_json::Value::String(status.to_string()));
191        self.set_persistent("message", serde_json::Value::String(message.into()));
192        self.set_persistent("satisfied", serde_json::Value::Bool(satisfied));
193        if status != "duplicate" {
194            self.remove_persistent("duplicateConstraintIDs");
195            self.remove_persistent("duplicateSignature");
196        }
197        if status != "satisfied" && status != "adjusted" {
198            self.remove_persistent("error");
199            self.remove_persistent("kernel");
200        }
201    }
202
203    /// The current status word (empty when the constraint never ran).
204    pub fn status(&self) -> &str {
205        self.persistent("status")
206            .and_then(|value| value.as_str())
207            .unwrap_or("")
208    }
209}
210
211// ===========================================================================
212// History-tail hook
213// ===========================================================================
214
215/// Run the request's assembly constraints against the just-built scene and
216/// install the post-solve session the exported ABI serves (`exports`). Called
217/// unconditionally at the tail of [`crate::feature_pipeline::execute_history`]
218/// — a request without an `assembly` block installs an EMPTY session, so a
219/// document switch can never serve stale constraint state.
220pub(crate) fn finish_history_run(
221    request: &crate::feature_pipeline::HistoryRequest,
222    scene: &mut crate::feature_pipeline::SceneMap,
223    env: &Env,
224) {
225    let mut state = request.assembly.clone().unwrap_or_default();
226    let outcome = if state.constraints.is_empty() {
227        lifecycle::LifecycleOutcome::empty()
228    } else {
229        lifecycle::run_constraints(&mut state, scene, env)
230    };
231    exports::install_session(
232        state,
233        scene.clone(),
234        request.expressions.clone(),
235        request.configurator.clone(),
236        outcome,
237    );
238}
239