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 flexible;
48pub(crate) mod infer;
49pub(crate) mod lifecycle;
50pub(crate) mod mapping;
51pub(crate) mod constraints;
52
53pub use exports::{
54    assembly_add_constraint_json, assembly_apply_document_json,
55    assembly_apply_inferred_constraints_json, assembly_component_entities_json, assembly_dof_json,
56    assembly_infer_constraints_json, assembly_inferable_types_json,
57    assembly_move_constraint_json, assembly_overlay_json, assembly_pose_updates_json,
58    assembly_drag_solve_json, assembly_frame_solve_json, assembly_remove_constraint_json, assembly_resolve_refs_json,
59    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    // The native doors: the same mutations with their errors as text. A native
63    // caller must use these — a `JsValue` error aborts a native build.
64    assembly_add_constraint_impl, assembly_apply_document_impl, assembly_move_constraint_impl,
65    assembly_drag_solve_impl, assembly_frame_solve_impl, assembly_flip_plan_json, assembly_rebase_mechanical_impl, assembly_remove_constraint_impl, assembly_run_solve_impl,
66    assembly_set_constraint_enabled_impl, assembly_set_constraint_open_impl,
67    assembly_update_constraint_impl,
68    // The session transfer: an owned copy of one thread's session for another.
69    assembly_session_export, assembly_session_import, assembly_session_serial, RetiredSession, SessionTransfer,
70};
71pub use constraints::{
72    constraint_hidden_params, constraint_schema_catalogue, constraint_type, ConstraintTypeDef,
73    CONSTRAINT_TYPES,
74};
75// The pose write-back encoder (`mapping::transform_to_pose_params`): the ONE
76// matrix → `{translate, rotateEulerDeg}` (intrinsic XYZ, degrees) conversion,
77// re-exported so out-of-crate ACOMP authors reuse it instead of re-deriving
78// the Euler decomposition (and its gimbal-lock branch).
79pub use mapping::transform_to_pose_params;
80
81// ===========================================================================
82// ===========================================================================
83
84/// The document's `assembly` block: the ordered constraint list plus the
85/// persistent id counter (monotonic, never reused — mirrors the feature
86/// counter's contract).
87#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
88pub struct AssemblyState {
89    #[serde(default)]
90    pub constraints: Vec<ConstraintEntry>,
91    #[serde(default, rename = "idCounter")]
92    pub id_counter: u64,
93}
94
95/// One persisted constraint: `{ type, inputParams, persistentData, enabled,
96/// open }`. `inputParams` carries `id`, `elements` (selection ref strings) and
97/// the type-specific params; `persistentData` is the solver-state merge target
98/// (status/message/diagnostics/orientation cache).
99#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
100pub struct ConstraintEntry {
101    #[serde(rename = "type")]
102    pub constraint_type: String,
103    #[serde(rename = "inputParams", default = "empty_object")]
104    pub input_params: serde_json::Value,
105    #[serde(rename = "persistentData", default = "empty_object")]
106    pub persistent_data: serde_json::Value,
107    #[serde(default = "default_true")]
108    pub enabled: bool,
109    #[serde(default)]
110    pub open: bool,
111}
112
113fn empty_object() -> serde_json::Value {
114    serde_json::Value::Object(serde_json::Map::new())
115}
116
117fn default_true() -> bool {
118    true
119}
120
121impl ConstraintEntry {
122    /// The constraint id (`inputParams.id`), empty when unset.
123    pub fn id(&self) -> &str {
124        self.input_params
125            .get("id")
126            .and_then(|value| value.as_str())
127            .unwrap_or("")
128    }
129
130    /// The selection refs (`inputParams.elements`), string entries only.
131    pub fn elements(&self) -> Vec<String> {
132        self.input_params
133            .get("elements")
134            .and_then(|value| value.as_array())
135            .map(|items| {
136                items
137                    .iter()
138                    .filter_map(|item| item.as_str())
139                    .map(str::to_string)
140                    .collect()
141            })
142            .unwrap_or_default()
143    }
144
145    /// A boolean param (absent → false).
146    pub fn flag(&self, key: &str) -> bool {
147        self.input_params
148            .get(key)
149            .and_then(|value| value.as_bool())
150            .unwrap_or(false)
151    }
152
153    /// A numeric param, expression-capable (a string evaluates against the
154    /// part's expression scope). Absent → `default`.
155    pub fn number(&self, key: &str, env: &Env, default: f64) -> Result<f64, String> {
156        match self.input_params.get(key) {
157            None | Some(serde_json::Value::Null) => Ok(default),
158            Some(serde_json::Value::Number(number)) => number
159                .as_f64()
160                .ok_or_else(|| format!("param `{key}` is not a finite number")),
161            Some(serde_json::Value::String(source)) => env
162                .eval(source)
163                .map_err(|error| format!("param `{key}`: {error}")),
164            Some(other) => Err(format!(
165                "param `{key}` must be a number or expression string, found {other}"
166            )),
167        }
168    }
169
170    /// Merge a key into `persistentData` (created as an object when missing).
171    pub fn set_persistent(&mut self, key: &str, value: serde_json::Value) {
172        if !self.persistent_data.is_object() {
173            self.persistent_data = empty_object();
174        }
175        if let Some(map) = self.persistent_data.as_object_mut() {
176            map.insert(key.to_string(), value);
177        }
178    }
179
180    /// Remove a key from `persistentData` (no-op when absent).
181    pub fn remove_persistent(&mut self, key: &str) {
182        if let Some(map) = self.persistent_data.as_object_mut() {
183            map.remove(key);
184        }
185    }
186
187    /// A `persistentData` field, if present.
188    pub fn persistent(&self, key: &str) -> Option<&serde_json::Value> {
189        self.persistent_data.get(key)
190    }
191
192    /// Set the run status vocabulary trio (`status`, `message`, `satisfied`)
193    /// and clear stale duplicate bookkeeping unless this IS a duplicate mark.
194    pub fn set_status(&mut self, status: &str, message: impl Into<String>, satisfied: bool) {
195        self.set_persistent("status", serde_json::Value::String(status.to_string()));
196        self.set_persistent("message", serde_json::Value::String(message.into()));
197        self.set_persistent("satisfied", serde_json::Value::Bool(satisfied));
198        if status != "duplicate" {
199            self.remove_persistent("duplicateConstraintIDs");
200            self.remove_persistent("duplicateSignature");
201        }
202        if status != "satisfied" && status != "adjusted" {
203            self.remove_persistent("error");
204            self.remove_persistent("kernel");
205        }
206        if status != "conflict" {
207            self.remove_persistent("conflictWith");
208            self.remove_persistent("suppressSuggested");
209        }
210    }
211
212    /// The current status word (empty when the constraint never ran).
213    pub fn status(&self) -> &str {
214        self.persistent("status")
215            .and_then(|value| value.as_str())
216            .unwrap_or("")
217    }
218}
219
220// ===========================================================================
221// History-tail hook
222// ===========================================================================
223
224/// Run the request's assembly constraints against the just-built scene and
225/// install the post-solve session the exported ABI serves (`exports`). Called
226/// unconditionally at the tail of [`crate::feature_pipeline::execute_history`]
227/// — a request without an `assembly` block installs an EMPTY session, so a
228/// document switch can never serve stale constraint state.
229pub(crate) fn finish_history_run(
230    request: &crate::feature_pipeline::HistoryRequest,
231    scene: &mut crate::feature_pipeline::SceneMap,
232    env: &Env,
233) {
234    let mut state = request.assembly.clone().unwrap_or_default();
235    let mut outcome = if state.constraints.is_empty() {
236        lifecycle::LifecycleOutcome::empty()
237    } else {
238        lifecycle::run_constraints(&mut state, scene, env)
239    };
240    follow_pattern_seeds(scene, &mut outcome);
241    exports::install_session(
242        state,
243        scene.clone(),
244        request.expressions.clone(),
245        request.configurator.clone(),
246        outcome,
247    );
248}
249
250/// Re-pose every Pattern Component instance onto its seed's pose as the solve
251/// left it (`delta · seed`), so instances follow a mated seed in the same run.
252/// A moved instance is reported like a solved body (its solids re-tessellate)
253/// but writes no pose back: it has no feature of its own to fold into.
254fn follow_pattern_seeds(
255    scene: &mut crate::feature_pipeline::SceneMap,
256    outcome: &mut lifecycle::LifecycleOutcome,
257) {
258    let follows: Vec<(String, crate::AffineTransform)> = scene
259        .components
260        .values()
261        .filter_map(|record| {
262            let follow = record.pattern.as_ref()?;
263            let seed = scene.components.get(&follow.seed)?;
264            let delta = match &follow.driver {
265                // HOLES: the offset is the driver's, applied through its solved pose.
266                Some(driver) => {
267                    let driver = scene.components.get(driver)?.transform;
268                    let inverse = driver.rigid_inverse().ok()?;
269                    let compose = crate::feature_pipeline::component::compose;
270                    compose(&compose(&driver, &follow.delta).ok()?, &inverse).ok()?
271                }
272                None => follow.delta,
273            };
274            let target = crate::feature_pipeline::component::compose(&delta, &seed.transform).ok()?;
275            let moved = target
276                .elements
277                .iter()
278                .zip(record.transform.elements.iter())
279                .any(|(new, old)| (new - old).abs() > 1e-12);
280            moved.then(|| (record.id.clone(), target))
281        })
282        .collect();
283    for (id, target) in follows {
284        if crate::feature_pipeline::component::update_component_transform(scene, &id, target).is_ok() {
285            outcome.moved_solids.extend(scene.component_solids(&id));
286        }
287    }
288}
289