Skip to main content

brep_kernel/feature_pipeline/assembly/
exports.rs

1//! The assembly SESSION + exported ABI: constraint CRUD with auto-solve,
2//! manual solve, per-constraint status JSON, DOF summary JSON,
3//! overlay-geometry JSON, and the generic document fold that writes the solved
4//! state + poses back into the history document.
5//!
6//! The session is installed by the history-execution tail
7//! ([`super::finish_history_run`]) after EVERY run: the post-solve constraint
8//! state, a clone of the final scene map (names + resident handles — the
9//! handles stay owned by the history cache), and the expression sheet for
10//! expression-capable params. Constraint mutations between runs operate on the
11//! session and AUTO-SOLVE against the live resident geometry; the app folds
12//! the result back into its document via [`assembly_apply_document_json`]
13//! before the next history run (the pose-authority contract).
14
15use std::cell::RefCell;
16use std::collections::BTreeMap;
17
18use wasm_bindgen::prelude::*;
19
20use super::lifecycle::{self, LifecycleOutcome};
21use super::{constraints, mapping, AssemblyState, ConstraintEntry};
22use crate::feature_pipeline::{Env, SceneMap};
23
24struct Session {
25    state: AssemblyState,
26    scene: SceneMap,
27    expressions: String,
28    configurator: serde_json::Value,
29    /// Component id → solved `inputParams.transform` JSON. ACCUMULATES across
30    /// mutation solves (later solves overwrite per id); replaced wholesale by
31    /// the next history run.
32    pose_updates: BTreeMap<String, serde_json::Value>,
33    /// Component id → grounded write-back (`inputParams.isFixed`).
34    fixed_updates: BTreeMap<String, bool>,
35    /// The last solve's report (`ok` + diagnostics, or `error`).
36    report: serde_json::Value,
37}
38
39thread_local! {
40    static SESSION: RefCell<Option<Session>> = const { RefCell::new(None) };
41}
42
43/// Install the post-run session (the history tail). Pose/fixed updates start
44/// from THIS run's outcome — the previous session's pending updates are
45/// dropped (the run consumed the document they were meant for).
46pub(super) fn install_session(
47    state: AssemblyState,
48    scene: SceneMap,
49    expressions: String,
50    configurator: serde_json::Value,
51    outcome: LifecycleOutcome,
52) {
53    SESSION.with(|session| {
54        *session.borrow_mut() = Some(Session {
55            state,
56            scene,
57            expressions,
58            configurator,
59            pose_updates: outcome.pose_updates,
60            fixed_updates: outcome.fixed_updates,
61            report: outcome.report,
62        });
63    });
64}
65
66fn with_session<T>(f: impl FnOnce(&mut Session) -> Result<T, String>) -> Result<T, String> {
67    SESSION.with(|session| {
68        let mut session = session.borrow_mut();
69        let session = session
70            .as_mut()
71            .ok_or_else(|| "no assembly session (run a history first)".to_string())?;
72        f(session)
73    })
74}
75
76/// Re-solve the session state against the session scene, merging the
77/// outcome's write-backs (the auto-solve every mutation triggers). The
78/// returned report carries `movedSolids` (built by the lifecycle) — the
79/// display lane re-tessellates exactly those in-place-moved resident solids.
80fn solve_session(session: &mut Session) -> serde_json::Value {
81    let env = Env::build(&session.expressions, &session.configurator)
82        .unwrap_or_else(Env::poisoned);
83    let outcome = lifecycle::run_constraints(&mut session.state, &mut session.scene, &env);
84    session.pose_updates.extend(outcome.pose_updates);
85    session.fixed_updates.extend(outcome.fixed_updates);
86    session.report = outcome.report.clone();
87    outcome.report
88}
89
90// ===========================================================================
91// Read surface
92// ===========================================================================
93
94/// The current `assembly` block (post-solve) as JSON — the panel's list source
95/// and the state the app folds back into its document.
96#[wasm_bindgen]
97pub fn assembly_state_json() -> String {
98    SESSION.with(|session| {
99        session
100            .borrow()
101            .as_ref()
102            .map(|session| serde_json::to_value(&session.state).unwrap_or_default())
103            .unwrap_or_else(|| serde_json::to_value(AssemblyState::default()).unwrap_or_default())
104            .to_string()
105    })
106}
107
108/// Per-constraint status rows: `[{id, type, enabled, open, status, message,
109/// satisfied, error}]`.
110#[wasm_bindgen]
111pub fn assembly_statuses_json() -> String {
112    SESSION.with(|session| {
113        let rows: Vec<serde_json::Value> = session
114            .borrow()
115            .as_ref()
116            .map(|session| {
117                session
118                    .state
119                    .constraints
120                    .iter()
121                    .map(|entry| {
122                        serde_json::json!({
123                            "id": entry.id(),
124                            "type": entry.constraint_type,
125                            "enabled": entry.enabled,
126                            "open": entry.open,
127                            "status": entry.status(),
128                            "message": entry
129                                .persistent("message")
130                                .and_then(|value| value.as_str())
131                                .unwrap_or(""),
132                            "satisfied": entry
133                                .persistent("satisfied")
134                                .and_then(|value| value.as_bool())
135                                .unwrap_or(false),
136                            "error": entry.persistent("error").cloned(),
137                        })
138                    })
139                    .collect()
140            })
141            .unwrap_or_default();
142        serde_json::Value::Array(rows).to_string()
143    })
144}
145
146/// The last solve's DOF/diagnostics summary (`{ok, dof, rank, redundant,
147/// status, strategy, iterations, maxResidual, ...}` or `{ok:false, error}`).
148#[wasm_bindgen]
149pub fn assembly_dof_json() -> String {
150    SESSION.with(|session| {
151        session
152            .borrow()
153            .as_ref()
154            .map(|session| session.report.to_string())
155            .unwrap_or_else(|| serde_json::json!({ "ok": true, "mates": 0 }).to_string())
156    })
157}
158
159/// The pending generic feature write-backs: `{"poses": {componentId:
160/// transformParam}, "isFixed": {componentId: bool}}`. The transform param is
161/// `{translate, rotateEulerDeg (deg, intrinsic XYZ)}` — exactly the shape the
162/// ACOMP feature's `inputParams.transform` reader expects.
163#[wasm_bindgen]
164pub fn assembly_pose_updates_json() -> String {
165    SESSION.with(|session| {
166        session
167            .borrow()
168            .as_ref()
169            .map(|session| {
170                serde_json::json!({
171                    "poses": session.pose_updates,
172                    "isFixed": session.fixed_updates,
173                })
174                .to_string()
175            })
176            .unwrap_or_else(|| serde_json::json!({ "poses": {}, "isFixed": {} }).to_string())
177    })
178}
179
180/// Fold the session's solved state into a history DOCUMENT: replaces the
181/// top-level `assembly` block and writes each pending pose / grounded flag
182/// into the owning feature's `inputParams` (matched by `inputParams.id` —
183/// generic JSON, no dependency on the ACOMP feature shape). The app calls this
184/// on its document before persisting or re-running (the pose-authority
185/// write-back).
186#[wasm_bindgen]
187pub fn assembly_apply_document_json(document_json: &str) -> Result<String, JsValue> {
188    assembly_apply_document_impl(document_json).map_err(|error| JsValue::from_str(&error))
189}
190
191pub fn assembly_apply_document_impl(document_json: &str) -> Result<String, String> {
192    let mut document: serde_json::Value = serde_json::from_str(document_json)
193        .map_err(|error| format!("assembly document fold: bad document: {error}"))?;
194    with_session(|session| {
195        let object = document
196            .as_object_mut()
197            .ok_or_else(|| "assembly document fold: document must be an object".to_string())?;
198        object.insert(
199            "assembly".into(),
200            serde_json::to_value(&session.state)
201                .map_err(|error| format!("assembly state serialize: {error}"))?,
202        );
203        if let Some(features) = object.get_mut("features").and_then(|v| v.as_array_mut()) {
204            for feature in features {
205                let Some(params) = feature
206                    .get_mut("inputParams")
207                    .and_then(|value| value.as_object_mut())
208                else {
209                    continue;
210                };
211                let Some(id) = params.get("id").and_then(|value| value.as_str()) else {
212                    continue;
213                };
214                let id = id.to_string();
215                if let Some(pose) = session.pose_updates.get(&id) {
216                    // Numeric solver write-back must not erase authored motion
217                    // expressions. The evaluated component record owns that pose.
218                    fn has_expression(value: &serde_json::Value) -> bool {
219                        match value {
220                            serde_json::Value::String(_) => true,
221                            serde_json::Value::Array(a) => a.iter().any(has_expression),
222                            serde_json::Value::Object(o) => o.values().any(has_expression),
223                            _ => false,
224                        }
225                    }
226                    if !params.get("transform").is_some_and(has_expression) {
227                        params.insert("transform".into(), pose.clone());
228                    }
229                }
230                if let Some(&fixed) = session.fixed_updates.get(&id) {
231                    params.insert("isFixed".into(), serde_json::Value::Bool(fixed));
232                }
233            }
234        }
235        Ok(())
236    })?;
237    Ok(document.to_string())
238}
239
240/// Overlay geometry for the viewport graphics lane: per enabled constraint,
241/// the resolved WORLD anchor points/directions/geometry classes (post-solve),
242/// the status, the evaluated value, and (multi-element types) the element
243/// role groups — `[{id, type, status, message, anchors, directions, geoms,
244/// value, unit, target, groups}]`. Constraints whose selections do not
245/// resolve emit status/message only (no anchors).
246#[wasm_bindgen]
247pub fn assembly_overlay_json() -> String {
248    SESSION.with(|session| {
249        let session = session.borrow();
250        let Some(session) = session.as_ref() else {
251            return serde_json::Value::Array(Vec::new()).to_string();
252        };
253        let env = Env::build(&session.expressions, &session.configurator)
254            .unwrap_or_else(Env::poisoned);
255        let rows: Vec<serde_json::Value> = session
256            .state
257            .constraints
258            .iter()
259            .filter(|entry| entry.enabled)
260            .map(|entry| overlay_row(entry, &session.scene, &env))
261            .collect();
262        serde_json::Value::Array(rows).to_string()
263    })
264}
265
266fn overlay_row(entry: &ConstraintEntry, scene: &SceneMap, env: &Env) -> serde_json::Value {
267    let mut row = serde_json::json!({
268        "id": entry.id(),
269        "type": entry.constraint_type,
270        "status": entry.status(),
271        "message": entry
272            .persistent("message")
273            .and_then(|value| value.as_str())
274            .unwrap_or(""),
275    });
276    let elements = entry.elements();
277    let resolved: Vec<_> = elements
278        .iter()
279        .filter_map(|name| mapping::resolve_element(scene, name).ok())
280        .collect();
281    if resolved.len() != elements.len() || resolved.is_empty() {
282        return row;
283    }
284    let object = row.as_object_mut().expect("row is an object");
285    object.insert(
286        "anchors".into(),
287        serde_json::Value::Array(
288            resolved
289                .iter()
290                .map(|element| {
291                    let p = element.world.representative_point();
292                    serde_json::json!([p.x, p.y, p.z])
293                })
294                .collect(),
295        ),
296    );
297    object.insert(
298        "directions".into(),
299        serde_json::Value::Array(
300            resolved
301                .iter()
302                .map(|element| match mapping::direction_of(&element.world) {
303                    Some(direction) => serde_json::json!([direction.x, direction.y, direction.z]),
304                    None => serde_json::Value::Null,
305                })
306                .collect(),
307        ),
308    );
309    // Per-element geometry class, aligned index-wise with anchors/directions —
310    // the render-side distance-arrow builder needs to identify the BASE PLANE
311    // (the perpendicular-foot construction); a direction alone is ambiguous
312    // (lines and circles carry one too).
313    object.insert(
314        "geoms".into(),
315        serde_json::Value::Array(
316            resolved
317                .iter()
318                .map(|element| serde_json::json!(geometry_tag(&element.world)))
319                .collect(),
320        ),
321    );
322    // Measured value/target — and the element ROLE groups a multi-element type
323    // inferred (center's `[width pair, tab]`) — via the mapper on a THROWAWAY
324    // clone (the mapper maintains the orientation-preference cache; an
325    // overlay read must not mutate persisted state).
326    // `fixed` has no mapper (it grounds a body; nothing to measure).
327    if entry.constraint_type == "fixed" {
328        return row;
329    }
330    let mut scratch = entry.clone();
331    if let Ok(mapped) = mapping::map_constraint(&mut scratch, &resolved, env) {
332        if let Some((value, unit)) = mapped.measured {
333            object.insert("value".into(), serde_json::json!(value));
334            object.insert("unit".into(), serde_json::json!(unit));
335        }
336        if let Some(axis) = mapped.angle_axis {
337            object.insert("angleAxis".into(), serde_json::json!([axis.x, axis.y, axis.z]));
338        }
339        if let Some(target) = mapped.target {
340            object.insert("target".into(), serde_json::json!(target));
341        }
342        if !mapped.groups.is_empty() {
343            object.insert("groups".into(), serde_json::json!(mapped.groups));
344        }
345    }
346    row
347}
348
349/// The coarse geometry class of a resolved selection (the overlay row's
350/// `geoms` vocabulary — lowercase `SelectionGeometry` variant names).
351fn geometry_tag(geometry: &crate::SelectionGeometry) -> &'static str {
352    match geometry {
353        crate::SelectionGeometry::Plane { .. } => "plane",
354        crate::SelectionGeometry::Line { .. } => "line",
355        crate::SelectionGeometry::Axis { .. } => "axis",
356        crate::SelectionGeometry::Circle { .. } => "circle",
357        crate::SelectionGeometry::Sphere { .. } => "sphere",
358        crate::SelectionGeometry::Point { .. } => "point",
359    }
360}
361
362// ===========================================================================
363// Mutation surface
364// ===========================================================================
365
366/// Add a constraint of `constraint_type` with the given `inputParams` JSON
367/// (elements etc.; `id` is minted from the type's short name + the persistent
368/// counter when absent). Auto-solves; returns `{id, report}`.
369#[wasm_bindgen]
370pub fn assembly_add_constraint_json(
371    constraint_type: &str,
372    params_json: &str,
373) -> Result<String, JsValue> {
374    assembly_add_constraint_impl(constraint_type, params_json).map_err(|error| JsValue::from_str(&error))
375}
376
377pub fn assembly_add_constraint_impl(constraint_type: &str, params_json: &str) -> Result<String, String> {
378    let def = constraints::constraint_type(constraint_type)
379        .ok_or_else(|| format!("Unknown constraint type: {constraint_type}"))?;
380    let mut params: serde_json::Value = serde_json::from_str(params_json)
381        .map_err(|error| format!("constraint params: {error}"))?;
382    if !params.is_object() {
383        return Err("constraint params must be a JSON object".to_string());
384    }
385    with_session(|session| {
386        let id = match params.get("id").and_then(|value| value.as_str()) {
387            Some(id) if !id.is_empty() => id.to_string(),
388            _ => {
389                session.state.id_counter += 1;
390                let id = format!("{}{}", def.short_name, session.state.id_counter);
391                params
392                    .as_object_mut()
393                    .expect("checked object above")
394                    .insert("id".into(), serde_json::Value::String(id.clone()));
395                id
396            }
397        };
398        session.state.constraints.push(ConstraintEntry {
399            constraint_type: constraint_type.to_string(),
400            input_params: params.clone(),
401            persistent_data: serde_json::Value::Object(serde_json::Map::new()),
402            enabled: true,
403            open: true,
404        });
405        let report = solve_session(session);
406        Ok(serde_json::json!({ "id": id, "report": report }).to_string())
407    })
408}
409
410/// Replace a constraint's `inputParams` (the dialog commit). Auto-solves;
411/// returns the solve report.
412#[wasm_bindgen]
413pub fn assembly_update_constraint_json(id: &str, params_json: &str) -> Result<String, JsValue> {
414    assembly_update_constraint_impl(id, params_json).map_err(|error| JsValue::from_str(&error))
415}
416
417pub fn assembly_update_constraint_impl(id: &str, params_json: &str) -> Result<String, String> {
418    let mut params: serde_json::Value = serde_json::from_str(params_json)
419        .map_err(|error| format!("constraint params: {error}"))?;
420    if !params.is_object() {
421        return Err("constraint params must be a JSON object".to_string());
422    }
423    if params.get("id").and_then(|value| value.as_str()).is_none() {
424        params
425            .as_object_mut()
426            .expect("checked object above")
427            .insert("id".into(), serde_json::Value::String(id.to_string()));
428    }
429    with_session(|session| {
430        let entry = find_entry(session, id)?;
431        entry.input_params = params.clone();
432        Ok(solve_session(session).to_string())
433    })
434}
435
436/// Delete a constraint. Auto-solves; returns the solve report.
437#[wasm_bindgen]
438pub fn assembly_remove_constraint_json(id: &str) -> Result<String, JsValue> {
439    assembly_remove_constraint_impl(id).map_err(|error| JsValue::from_str(&error))
440}
441
442pub fn assembly_remove_constraint_impl(id: &str) -> Result<String, String> {
443    with_session(|session| {
444        let index = find_index(session, id)?;
445        session.state.constraints.remove(index);
446        Ok(solve_session(session).to_string())
447    })
448}
449
450/// Enable/disable a constraint. Auto-solves; returns the solve report.
451#[wasm_bindgen]
452pub fn assembly_set_constraint_enabled_json(id: &str, enabled: bool) -> Result<String, JsValue> {
453    assembly_set_constraint_enabled_impl(id, enabled).map_err(|error| JsValue::from_str(&error))
454}
455
456pub fn assembly_set_constraint_enabled_impl(id: &str, enabled: bool) -> Result<String, String> {
457    with_session(|session| {
458        find_entry(session, id)?.enabled = enabled;
459        Ok(solve_session(session).to_string())
460    })
461}
462
463/// Persist a constraint row's dialog expansion state (view state — no solve).
464#[wasm_bindgen]
465pub fn assembly_set_constraint_open_json(id: &str, open: bool) -> Result<(), JsValue> {
466    assembly_set_constraint_open_impl(id, open).map_err(|error| JsValue::from_str(&error))
467}
468
469pub fn assembly_set_constraint_open_impl(id: &str, open: bool) -> Result<(), String> {
470    with_session(|session| {
471        find_entry(session, id)?.open = open;
472        Ok(())
473    })
474}
475
476/// Reorder a constraint to `index` (clamped). Auto-solves; returns the report.
477#[wasm_bindgen]
478pub fn assembly_move_constraint_json(id: &str, index: usize) -> Result<String, JsValue> {
479    assembly_move_constraint_impl(id, index).map_err(|error| JsValue::from_str(&error))
480}
481
482pub fn assembly_move_constraint_impl(id: &str, index: usize) -> Result<String, String> {
483    with_session(|session| {
484        let from = find_index(session, id)?;
485        let entry = session.state.constraints.remove(from);
486        let to = index.min(session.state.constraints.len());
487        session.state.constraints.insert(to, entry);
488        Ok(solve_session(session).to_string())
489    })
490}
491
492// ===========================================================================
493// Automatic inference (super::infer)
494// ===========================================================================
495
496/// The inferable constraint types, joined to the catalogue: `[{type, label,
497/// icon, longName, detects, defaultOn}]`. Session-free — the dialog can list
498/// what the button offers before any document is open.
499#[wasm_bindgen]
500pub fn assembly_inferable_types_json() -> String {
501    super::infer::inferable_types_json().to_string()
502}
503
504/// SCAN the live scene for constraints its component placement implies, without
505/// creating anything. `options_json` is [`super::infer::InferOptions`] (`{}` for
506/// the defaults). Never `Err`: a missing session or a bad options object comes
507/// back as `{ok: false, error}` — a `JsValue` error aborts a native build.
508#[wasm_bindgen]
509pub fn assembly_infer_constraints_json(options_json: &str) -> String {
510    match infer_scan(options_json) {
511        Ok(report) => report.to_string(),
512        Err(error) => refusal(&error),
513    }
514}
515
516/// Scan and CREATE: every accepted candidate becomes a constraint, then ONE
517/// solve runs for the whole batch (each inferred constraint is already
518/// satisfied at the pose it was read from, so a healthy assembly does not
519/// move). Returns `{ok, created: [id…], report, solve}`.
520#[wasm_bindgen]
521pub fn assembly_apply_inferred_constraints_json(options_json: &str) -> String {
522    match infer_apply(options_json) {
523        Ok(report) => report.to_string(),
524        Err(error) => refusal(&error),
525    }
526}
527
528fn refusal(error: &str) -> String {
529    serde_json::json!({ "ok": false, "error": error }).to_string()
530}
531
532fn infer_scan(options_json: &str) -> Result<serde_json::Value, String> {
533    let options = super::infer::InferOptions::parse(options_json)?;
534    with_session(|session| Ok(super::infer::scan(&session.state, &session.scene, &options).report()))
535}
536
537fn infer_apply(options_json: &str) -> Result<serde_json::Value, String> {
538    let options = super::infer::InferOptions::parse(options_json)?;
539    with_session(|session| {
540        let scan = super::infer::scan(&session.state, &session.scene, &options);
541        let report = scan.report();
542        let created = super::infer::apply(&mut session.state, &scan.candidates);
543        let solve = if created.is_empty() {
544            serde_json::Value::Null
545        } else {
546            solve_session(session)
547        };
548        Ok(serde_json::json!({
549            "ok": true,
550            "created": created,
551            "report": report,
552            "solve": solve,
553        }))
554    })
555}
556
557/// Manual solve (the panel's Solve button). Returns the solve report.
558#[wasm_bindgen]
559pub fn assembly_run_solve_json() -> Result<String, JsValue> {
560    assembly_run_solve_impl().map_err(|error| JsValue::from_str(&error))
561}
562
563pub fn assembly_run_solve_impl() -> Result<String, String> {
564    with_session(|session| Ok(solve_session(session).to_string()))
565}
566
567fn find_index(session: &Session, id: &str) -> Result<usize, String> {
568    session
569        .state
570        .constraints
571        .iter()
572        .position(|entry| entry.id() == id)
573        .ok_or_else(|| format!("unknown constraint '{id}'"))
574}
575
576fn find_entry<'a>(session: &'a mut Session, id: &str) -> Result<&'a mut ConstraintEntry, String> {
577    let index = find_index(session, id)?;
578    Ok(&mut session.state.constraints[index])
579}