Skip to main content

brep_kernel/feature_pipeline/
assembly.rs

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