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/// The refusal for a `fixed` constraint (kernel ABI, app command, batch):
82/// Fixed is the component instance's own setting, never a constraint.
83pub const FIXED_IS_NOT_A_CONSTRAINT: &str = "Fixed is not a constraint: a part is fixed by its own Fixed setting \
84(the component's `isFixed`). Use component_set_fixed (Fix/Unfix on the component's row or menu), or give the \
85component `isFixed: true` in its params";
86
87// ===========================================================================
88// ===========================================================================
89
90/// The document's `assembly` block: the ordered constraint list plus the
91/// persistent id counter (monotonic, never reused — mirrors the feature
92/// counter's contract).
93#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
94pub struct AssemblyState {
95    #[serde(default)]
96    pub constraints: Vec<ConstraintEntry>,
97    #[serde(default, rename = "idCounter")]
98    pub id_counter: u64,
99}
100
101/// One persisted constraint: `{ type, inputParams, persistentData, enabled,
102/// open }`. `inputParams` carries `id`, `elements` (selection ref strings) and
103/// the type-specific params; `persistentData` is the solver-state merge target
104/// (status/message/diagnostics/orientation cache).
105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
106pub struct ConstraintEntry {
107    #[serde(rename = "type")]
108    pub constraint_type: String,
109    #[serde(rename = "inputParams", default = "empty_object")]
110    pub input_params: serde_json::Value,
111    #[serde(rename = "persistentData", default = "empty_object")]
112    pub persistent_data: serde_json::Value,
113    #[serde(default = "default_true")]
114    pub enabled: bool,
115    #[serde(default)]
116    pub open: bool,
117}
118
119fn empty_object() -> serde_json::Value {
120    serde_json::Value::Object(serde_json::Map::new())
121}
122
123fn default_true() -> bool {
124    true
125}
126
127impl ConstraintEntry {
128    /// The constraint id (`inputParams.id`), empty when unset.
129    pub fn id(&self) -> &str {
130        self.input_params
131            .get("id")
132            .and_then(|value| value.as_str())
133            .unwrap_or("")
134    }
135
136    /// The selection refs (`inputParams.elements`), string entries only.
137    pub fn elements(&self) -> Vec<String> {
138        self.input_params
139            .get("elements")
140            .and_then(|value| value.as_array())
141            .map(|items| {
142                items
143                    .iter()
144                    .filter_map(|item| item.as_str())
145                    .map(str::to_string)
146                    .collect()
147            })
148            .unwrap_or_default()
149    }
150
151    /// A boolean param (absent → false).
152    pub fn flag(&self, key: &str) -> bool {
153        self.input_params
154            .get(key)
155            .and_then(|value| value.as_bool())
156            .unwrap_or(false)
157    }
158
159    /// A numeric param, expression-capable (a string evaluates against the
160    /// part's expression scope). Absent → `default`.
161    pub fn number(&self, key: &str, env: &Env, default: f64) -> Result<f64, String> {
162        match self.input_params.get(key) {
163            None | Some(serde_json::Value::Null) => Ok(default),
164            Some(serde_json::Value::Number(number)) => number
165                .as_f64()
166                .ok_or_else(|| format!("param `{key}` is not a finite number")),
167            Some(serde_json::Value::String(source)) => env
168                .eval(source)
169                .map_err(|error| format!("param `{key}`: {error}")),
170            Some(other) => Err(format!(
171                "param `{key}` must be a number or expression string, found {other}"
172            )),
173        }
174    }
175
176    /// Merge a key into `persistentData` (created as an object when missing).
177    pub fn set_persistent(&mut self, key: &str, value: serde_json::Value) {
178        if !self.persistent_data.is_object() {
179            self.persistent_data = empty_object();
180        }
181        if let Some(map) = self.persistent_data.as_object_mut() {
182            map.insert(key.to_string(), value);
183        }
184    }
185
186    /// Remove a key from `persistentData` (no-op when absent).
187    pub fn remove_persistent(&mut self, key: &str) {
188        if let Some(map) = self.persistent_data.as_object_mut() {
189            map.remove(key);
190        }
191    }
192
193    /// A `persistentData` field, if present.
194    pub fn persistent(&self, key: &str) -> Option<&serde_json::Value> {
195        self.persistent_data.get(key)
196    }
197
198    /// Set the run status vocabulary trio (`status`, `message`, `satisfied`)
199    /// and clear stale duplicate bookkeeping unless this IS a duplicate mark.
200    pub fn set_status(&mut self, status: &str, message: impl Into<String>, satisfied: bool) {
201        self.set_persistent("status", serde_json::Value::String(status.to_string()));
202        self.set_persistent("message", serde_json::Value::String(message.into()));
203        self.set_persistent("satisfied", serde_json::Value::Bool(satisfied));
204        if status != "duplicate" {
205            self.remove_persistent("duplicateConstraintIDs");
206            self.remove_persistent("duplicateSignature");
207        }
208        if status != "satisfied" && status != "adjusted" {
209            self.remove_persistent("error");
210            self.remove_persistent("kernel");
211        }
212        if status != "conflict" {
213            self.remove_persistent("conflictWith");
214            self.remove_persistent("suppressSuggested");
215        }
216    }
217
218    /// The current status word (empty when the constraint never ran).
219    pub fn status(&self) -> &str {
220        self.persistent("status")
221            .and_then(|value| value.as_str())
222            .unwrap_or("")
223    }
224}
225
226// ===========================================================================
227// History-tail hook
228// ===========================================================================
229
230/// Run the request's assembly constraints against the just-built scene and
231/// install the post-solve session the exported ABI serves (`exports`). Called
232/// unconditionally at the tail of [`crate::feature_pipeline::execute_history`]
233/// — a request without an `assembly` block installs an EMPTY session, so a
234/// document switch can never serve stale constraint state.
235pub(crate) fn finish_history_run(
236    request: &crate::feature_pipeline::HistoryRequest,
237    scene: &mut crate::feature_pipeline::SceneMap,
238    env: &Env,
239) {
240    let mut state = request.assembly.clone().unwrap_or_default();
241    let mut outcome = if state.constraints.is_empty() {
242        lifecycle::LifecycleOutcome::empty()
243    } else {
244        lifecycle::run_constraints(&mut state, scene, env)
245    };
246    follow_pattern_seeds(scene, &mut outcome);
247    exports::install_session(
248        state,
249        scene.clone(),
250        request.expressions.clone(),
251        request.configurator.clone(),
252        outcome,
253    );
254}
255
256/// Re-pose every Pattern Component instance onto its seed's pose as the solve
257/// left it (`delta · seed`), so instances follow a mated seed in the same run.
258/// A moved instance is reported like a solved body (its solids re-tessellate)
259/// but writes no pose back: it has no feature of its own to fold into.
260fn follow_pattern_seeds(
261    scene: &mut crate::feature_pipeline::SceneMap,
262    outcome: &mut lifecycle::LifecycleOutcome,
263) {
264    let follows: Vec<(String, crate::AffineTransform)> = scene
265        .components
266        .values()
267        .filter_map(|record| {
268            let follow = record.pattern.as_ref()?;
269            let seed = scene.components.get(&follow.seed)?;
270            let delta = match &follow.driver {
271                // HOLES: the offset is the driver's, applied through its solved pose.
272                Some(driver) => {
273                    let driver = scene.components.get(driver)?.transform;
274                    let inverse = driver.rigid_inverse().ok()?;
275                    let compose = crate::feature_pipeline::component::compose;
276                    compose(&compose(&driver, &follow.delta).ok()?, &inverse).ok()?
277                }
278                None => follow.delta,
279            };
280            let target = crate::feature_pipeline::component::compose(&delta, &seed.transform).ok()?;
281            let moved = target
282                .elements
283                .iter()
284                .zip(record.transform.elements.iter())
285                .any(|(new, old)| (new - old).abs() > 1e-12);
286            moved.then(|| (record.id.clone(), target))
287        })
288        .collect();
289    for (id, target) in follows {
290        if crate::feature_pipeline::component::update_component_transform(scene, &id, target).is_ok() {
291            outcome.moved_solids.extend(scene.component_solids(&id));
292        }
293    }
294}
295