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
24#[derive(Clone)]
25struct Session {
26    state: AssemblyState,
27    scene: SceneMap,
28    expressions: String,
29    configurator: serde_json::Value,
30    /// Component id → solved `inputParams.transform` JSON. ACCUMULATES across
31    /// mutation solves (later solves overwrite per id); replaced wholesale by
32    /// the next history run.
33    pose_updates: BTreeMap<String, serde_json::Value>,
34    /// Component id → grounded write-back (`inputParams.isFixed`).
35    fixed_updates: BTreeMap<String, bool>,
36    /// The last solve's report (`ok` + diagnostics, or `error`).
37    report: serde_json::Value,
38}
39
40thread_local! {
41    static SESSION: RefCell<Option<Session>> = const { RefCell::new(None) };
42    /// Bumped by every install (a run's tail or an import): which session this
43    /// thread holds, so a holder can tell whether another document's run or
44    /// replay has replaced the one it installed.
45    static SESSION_SERIAL: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
46    /// Handles registered by the last [`assembly_session_import`]: owned by the
47    /// imported session alone, freed when any other session replaces it.
48    static IMPORTED: RefCell<Vec<u32>> = const { RefCell::new(Vec::new()) };
49}
50
51/// Replace this thread's session. What it replaces, and the solids a previous
52/// IMPORT owned (out of the registry already), are handed back for the caller
53/// to drop: freeing a few hundred solids costs milliseconds.
54fn replace_session(next: Session) -> RetiredSession {
55    let solids = IMPORTED
56        .with(|imported| std::mem::take(&mut *imported.borrow_mut()))
57        .into_iter()
58        .filter_map(crate::take_registered_solid)
59        .collect();
60    let session = SESSION.with(|session| session.borrow_mut().replace(next));
61    SESSION_SERIAL.with(|serial| serial.set(serial.get() + 1));
62    RetiredSession { _session: session, _solids: solids }
63}
64
65/// A replaced session and the solids only it owned, to drop where it costs
66/// nothing (another thread). Dropping it is all there is to do with it.
67pub struct RetiredSession {
68    _session: Option<Session>,
69    _solids: Vec<crate::BrepSolid>,
70}
71
72/// Which session this thread holds (see `SESSION_SERIAL`); 0 = none yet.
73pub fn assembly_session_serial() -> u64 {
74    SESSION_SERIAL.with(std::cell::Cell::get)
75}
76
77/// An owned, self-contained copy of a thread's assembly session: the session
78/// and a clone of every solid its scene names, keyed by the handle it had
79/// THERE. It shares nothing with the thread it came from, so that thread may
80/// free or reuse every handle afterwards ([`assembly_session_import`]).
81pub struct SessionTransfer {
82    session: Session,
83    solids: Vec<(u32, crate::BrepSolid)>,
84}
85
86impl std::fmt::Debug for SessionTransfer {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        f.debug_struct("SessionTransfer").field("solids", &self.solids.len()).finish()
89    }
90}
91
92/// Copy this thread's session for another thread (`None` without one).
93pub fn assembly_session_export() -> Option<SessionTransfer> {
94    SESSION.with(|session| {
95        let session = session.borrow();
96        let session = session.as_ref()?;
97        let scene = &session.scene;
98        let mut handles: Vec<u32> = scene
99            .solids
100            .values()
101            .copied()
102            .chain(scene.faces.values().map(|face| face.handle))
103            .chain(scene.edges.values().map(|edge| edge.handle))
104            .collect();
105        handles.sort_unstable();
106        handles.dedup();
107        let solids = handles
108            .into_iter()
109            .filter_map(|handle| {
110                crate::with_registered_solid_str(handle, |solid| Ok(solid.clone())).ok().map(|solid| (handle, solid))
111            })
112            .collect();
113        Some(SessionTransfer { session: session.clone(), solids })
114    })
115}
116
117/// Install a [`SessionTransfer`] as this thread's session: every solid is
118/// registered HERE under a fresh handle and the scene is re-pointed at it.
119/// Returns what it replaced (the previous session and the solids a previous
120/// import owned) for the caller to drop.
121pub fn assembly_session_import(transfer: SessionTransfer) -> RetiredSession {
122    let SessionTransfer { mut session, solids } = transfer;
123    let mut fresh = std::collections::HashMap::with_capacity(solids.len());
124    let mut owned = Vec::with_capacity(solids.len());
125    for (old, solid) in solids {
126        let handle = crate::register_solid_value(solid);
127        fresh.insert(old, handle);
128        owned.push(handle);
129    }
130    // A handle with no copy (its solid was gone there) must not alias a
131    // solid that happens to hold the same number here.
132    let remap = |handle: &mut u32| *handle = fresh.get(handle).copied().unwrap_or(u32::MAX);
133    let scene = &mut session.scene;
134    scene.solids.values_mut().for_each(remap);
135    scene.faces.values_mut().for_each(|face| remap(&mut face.handle));
136    scene.edges.values_mut().for_each(|edge| remap(&mut edge.handle));
137    let retired = replace_session(session);
138    IMPORTED.with(|imported| *imported.borrow_mut() = owned);
139    retired
140}
141
142/// Install the post-run session (the history tail). Pose/fixed updates start
143/// from THIS run's outcome — the previous session's pending updates are
144/// dropped (the run consumed the document they were meant for).
145pub(super) fn install_session(
146    state: AssemblyState,
147    scene: SceneMap,
148    expressions: String,
149    configurator: serde_json::Value,
150    outcome: LifecycleOutcome,
151) {
152    drop(replace_session(Session {
153        state,
154        scene,
155        expressions,
156        configurator,
157        pose_updates: outcome.pose_updates,
158        fixed_updates: outcome.fixed_updates,
159        report: outcome.report,
160    }));
161}
162
163fn with_session<T>(f: impl FnOnce(&mut Session) -> Result<T, String>) -> Result<T, String> {
164    SESSION.with(|session| {
165        let mut session = session.borrow_mut();
166        let session = session
167            .as_mut()
168            .ok_or_else(|| "no assembly session (run a history first)".to_string())?;
169        f(session)
170    })
171}
172
173/// Re-solve the session state against the session scene, merging the
174/// outcome's write-backs (the auto-solve every mutation triggers). The
175/// returned report carries `movedSolids` (built by the lifecycle) — the
176/// display lane re-tessellates exactly those in-place-moved resident solids.
177fn solve_session(session: &mut Session) -> serde_json::Value {
178    let env = Env::build(&session.expressions, &session.configurator)
179        .unwrap_or_else(Env::poisoned);
180    let outcome = lifecycle::run_constraints(&mut session.state, &mut session.scene, &env);
181    session.pose_updates.extend(outcome.pose_updates);
182    session.fixed_updates.extend(outcome.fixed_updates);
183    session.report = outcome.report.clone();
184    outcome.report
185}
186
187// ===========================================================================
188// Read surface
189// ===========================================================================
190
191/// The current `assembly` block (post-solve) as JSON — the panel's list source
192/// and the state the app folds back into its document.
193#[wasm_bindgen]
194pub fn assembly_state_json() -> String {
195    SESSION.with(|session| {
196        session
197            .borrow()
198            .as_ref()
199            .map(|session| serde_json::to_value(&session.state).unwrap_or_default())
200            .unwrap_or_else(|| serde_json::to_value(AssemblyState::default()).unwrap_or_default())
201            .to_string()
202    })
203}
204
205/// Per-constraint status rows: `[{id, type, enabled, open, status, message,
206/// satisfied, error}]`.
207#[wasm_bindgen]
208pub fn assembly_statuses_json() -> String {
209    SESSION.with(|session| {
210        let rows: Vec<serde_json::Value> = session
211            .borrow()
212            .as_ref()
213            .map(|session| {
214                session
215                    .state
216                    .constraints
217                    .iter()
218                    .map(|entry| {
219                        serde_json::json!({
220                            "id": entry.id(),
221                            "type": entry.constraint_type,
222                            "enabled": entry.enabled,
223                            "open": entry.open,
224                            "status": entry.status(),
225                            "message": entry
226                                .persistent("message")
227                                .and_then(|value| value.as_str())
228                                .unwrap_or(""),
229                            "satisfied": entry
230                                .persistent("satisfied")
231                                .and_then(|value| value.as_bool())
232                                .unwrap_or(false),
233                            "error": entry.persistent("error").cloned(),
234                            "conflictWith": entry.persistent("conflictWith").cloned().unwrap_or(serde_json::json!([])),
235                            "suppressSuggested": entry
236                                .persistent("suppressSuggested")
237                                .and_then(|value| value.as_bool())
238                                .unwrap_or(false),
239                        })
240                    })
241                    .collect()
242            })
243            .unwrap_or_default();
244        serde_json::Value::Array(rows).to_string()
245    })
246}
247
248/// The last solve's DOF/diagnostics summary (`{ok, dof, rank, redundant,
249/// status, strategy, iterations, maxResidual, ...}` or `{ok:false, error}`).
250#[wasm_bindgen]
251pub fn assembly_dof_json() -> String {
252    SESSION.with(|session| {
253        session
254            .borrow()
255            .as_ref()
256            .map(|session| with_nested_constraints(&session.report, &session.scene).to_string())
257            .unwrap_or_else(|| serde_json::json!({ "ok": true, "mates": 0 }).to_string())
258    })
259}
260
261/// `report` plus `nestedConstraints`: the rows of every sub-assembly's own
262/// constraints (namespaced, every level), which solved inside the sub-assembly
263/// and are reported, not re-solved, here. Read from the session scene's
264/// component records, so a mutation re-solve (which replaces `report`) keeps
265/// them. Absent when no component carries any.
266fn with_nested_constraints(report: &serde_json::Value, scene: &SceneMap) -> serde_json::Value {
267    let rows: Vec<&crate::feature_pipeline::component::NestedConstraint> = scene
268        .iter_components()
269        .flat_map(|record| &record.nested_constraints)
270        .collect();
271    let mut report = report.clone();
272    if let (false, Some(map)) = (rows.is_empty(), report.as_object_mut()) {
273        map.insert(
274            "nestedConstraints".into(),
275            serde_json::to_value(rows).unwrap_or_default(),
276        );
277    }
278    report
279}
280
281/// The pending generic feature write-backs: `{"poses": {componentId:
282/// transformParam}, "isFixed": {componentId: bool}}`. The transform param is
283/// `{translate, rotateEulerDeg (deg, intrinsic XYZ)}` — exactly the shape the
284/// ACOMP feature's `inputParams.transform` reader expects.
285#[wasm_bindgen]
286pub fn assembly_pose_updates_json() -> String {
287    SESSION.with(|session| {
288        session
289            .borrow()
290            .as_ref()
291            .map(|session| {
292                serde_json::json!({
293                    "poses": session.pose_updates,
294                    "isFixed": session.fixed_updates,
295                })
296                .to_string()
297            })
298            .unwrap_or_else(|| serde_json::json!({ "poses": {}, "isFixed": {} }).to_string())
299    })
300}
301
302/// Fold the session's solved state into a history DOCUMENT: replaces the
303/// top-level `assembly` block and writes each pending pose / grounded flag
304/// into the owning feature's `inputParams` (matched by `inputParams.id` —
305/// generic JSON, no dependency on the ACOMP feature shape). The app calls this
306/// on its document before persisting or re-running (the pose-authority
307/// write-back).
308#[wasm_bindgen]
309pub fn assembly_apply_document_json(document_json: &str) -> Result<String, JsValue> {
310    assembly_apply_document_impl(document_json).map_err(|error| JsValue::from_str(&error))
311}
312
313pub fn assembly_apply_document_impl(document_json: &str) -> Result<String, String> {
314    let mut document: serde_json::Value = serde_json::from_str(document_json)
315        .map_err(|error| format!("assembly document fold: bad document: {error}"))?;
316    with_session(|session| {
317        let object = document
318            .as_object_mut()
319            .ok_or_else(|| "assembly document fold: document must be an object".to_string())?;
320        object.insert(
321            "assembly".into(),
322            serde_json::to_value(&session.state)
323                .map_err(|error| format!("assembly state serialize: {error}"))?,
324        );
325        if let Some(features) = object.get_mut("features").and_then(|v| v.as_array_mut()) {
326            for feature in features {
327                let Some(params) = feature
328                    .get_mut("inputParams")
329                    .and_then(|value| value.as_object_mut())
330                else {
331                    continue;
332                };
333                let Some(id) = params.get("id").and_then(|value| value.as_str()) else {
334                    continue;
335                };
336                let id = id.to_string();
337                if let Some(pose) = session.pose_updates.get(&id) {
338                    // Numeric solver write-back must not erase authored motion
339                    // expressions. The evaluated component record owns that pose.
340                    fn has_expression(value: &serde_json::Value) -> bool {
341                        match value {
342                            serde_json::Value::String(_) => true,
343                            serde_json::Value::Array(a) => a.iter().any(has_expression),
344                            serde_json::Value::Object(o) => o.values().any(has_expression),
345                            _ => false,
346                        }
347                    }
348                    if !params.get("transform").is_some_and(has_expression) {
349                        params.insert("transform".into(), pose.clone());
350                    }
351                }
352                // A flexible instance's occurrence poses (`ACOMP1:ACOMP2`),
353                // part-local, persist on the instance — never in the child
354                // document.
355                let prefix = format!("{id}:");
356                for (key, pose) in session.pose_updates.range(prefix.clone()..) {
357                    let Some(path) = key.strip_prefix(&prefix) else { break };
358                    let poses = params
359                        .entry("flexiblePoses")
360                        .or_insert_with(|| serde_json::Value::Object(Default::default()));
361                    if let Some(poses) = poses.as_object_mut() {
362                        poses.insert(path.to_string(), pose.clone());
363                    }
364                }
365                if let Some(&fixed) = session.fixed_updates.get(&id) {
366                    params.insert("isFixed".into(), serde_json::Value::Bool(fixed));
367                }
368            }
369        }
370        Ok(())
371    })?;
372    Ok(document.to_string())
373}
374
375/// Overlay geometry for the viewport graphics lane: per enabled constraint,
376/// the resolved WORLD anchor points/directions/geometry classes (post-solve),
377/// the status, the evaluated value, and (multi-element types) the element
378/// role groups — `[{id, type, status, message, anchors, directions, geoms,
379/// value, unit, target, groups}]`. Constraints whose selections do not
380/// resolve emit status/message only (no anchors).
381#[wasm_bindgen]
382pub fn assembly_overlay_json() -> String {
383    SESSION.with(|session| {
384        let session = session.borrow();
385        let Some(session) = session.as_ref() else {
386            return serde_json::Value::Array(Vec::new()).to_string();
387        };
388        let env = Env::build(&session.expressions, &session.configurator)
389            .unwrap_or_else(Env::poisoned);
390        let rows: Vec<serde_json::Value> = session
391            .state
392            .constraints
393            .iter()
394            .filter(|entry| entry.enabled)
395            .map(|entry| overlay_row(entry, &session.scene, &env))
396            .collect();
397        serde_json::Value::Array(rows).to_string()
398    })
399}
400
401fn overlay_row(entry: &ConstraintEntry, scene: &SceneMap, env: &Env) -> serde_json::Value {
402    let mut row = serde_json::json!({
403        "id": entry.id(),
404        "type": entry.constraint_type,
405        "status": entry.status(),
406        "message": entry
407            .persistent("message")
408            .and_then(|value| value.as_str())
409            .unwrap_or(""),
410    });
411    let elements = entry.elements();
412    let resolved: Vec<_> = elements
413        .iter()
414        .filter_map(|name| mapping::resolve_element(scene, name).ok())
415        .collect();
416    if resolved.len() != elements.len() || resolved.is_empty() {
417        return row;
418    }
419    let object = row.as_object_mut().expect("row is an object");
420    object.insert(
421        "anchors".into(),
422        serde_json::Value::Array(
423            resolved
424                .iter()
425                .map(|element| {
426                    let p = element.world.representative_point();
427                    serde_json::json!([p.x, p.y, p.z])
428                })
429                .collect(),
430        ),
431    );
432    object.insert(
433        "directions".into(),
434        serde_json::Value::Array(
435            resolved
436                .iter()
437                .map(|element| match mapping::direction_of(&element.world) {
438                    Some(direction) => serde_json::json!([direction.x, direction.y, direction.z]),
439                    None => serde_json::Value::Null,
440                })
441                .collect(),
442        ),
443    );
444    // Per-element geometry class, aligned index-wise with anchors/directions —
445    // the render-side distance-arrow builder needs to identify the BASE PLANE
446    // (the perpendicular-foot construction); a direction alone is ambiguous
447    // (lines and circles carry one too).
448    object.insert(
449        "geoms".into(),
450        serde_json::Value::Array(
451            resolved
452                .iter()
453                .map(|element| serde_json::json!(geometry_tag(&element.world)))
454                .collect(),
455        ),
456    );
457    // Measured value/target — and the element ROLE groups a multi-element type
458    // inferred (center's `[width pair, tab]`) — via the mapper on a THROWAWAY
459    // clone (the mapper maintains the orientation-preference cache; an
460    // overlay read must not mutate persisted state).
461    // `fixed` has no mapper (it grounds a body; nothing to measure).
462    if entry.constraint_type == "fixed" {
463        return row;
464    }
465    let mut scratch = entry.clone();
466    if let Ok(mapped) = mapping::map_constraint(&mut scratch, &resolved, env) {
467        if let Some((value, unit)) = mapped.measured {
468            object.insert("value".into(), serde_json::json!(value));
469            object.insert("unit".into(), serde_json::json!(unit));
470        }
471        if let Some(axis) = mapped.angle_axis {
472            object.insert("angleAxis".into(), serde_json::json!([axis.x, axis.y, axis.z]));
473        }
474        if let Some(target) = mapped.target {
475            object.insert("target".into(), serde_json::json!(target));
476        }
477        if !mapped.groups.is_empty() {
478            object.insert("groups".into(), serde_json::json!(mapped.groups));
479        }
480    }
481    row
482}
483
484/// One resolved element as the replace-component remap reads it: the geometry
485/// class plus the frame in the owning component's LOCAL space (a component
486/// swap keeps the ACOMP pose, so local frames of the old and the new part are
487/// directly comparable).
488fn local_element_json(element: &mapping::ResolvedElement) -> serde_json::Value {
489    let point = mapping::local_point(element);
490    let direction = mapping::direction_of(&element.local)
491        .map(|d| serde_json::json!([d.x, d.y, d.z]))
492        .unwrap_or(serde_json::Value::Null);
493    let radius = match element.local {
494        crate::SelectionGeometry::Axis { radius, .. } => radius,
495        crate::SelectionGeometry::Sphere { radius, .. }
496        | crate::SelectionGeometry::Circle { radius, .. } => Some(radius),
497        _ => None,
498    };
499    serde_json::json!({
500        "ref": element.name,
501        "component": element.component,
502        "geom": geometry_tag(&element.local),
503        "point": point,
504        "direction": direction,
505        "radius": radius,
506    })
507}
508
509/// Resolve selection refs against the live session WITHOUT touching any
510/// constraint: `refs_json` is `["ACOMP2:Part_PZ", …]`; the reply is one row
511/// per ref, `{ref, ok: true, component, geom, point, direction, radius}` in
512/// the owning component's local frame, or `{ref, ok: false, status, error}`.
513/// Read-only — the replace-component lane snapshots a mate's references
514/// before the swap with it.
515#[wasm_bindgen]
516pub fn assembly_resolve_refs_json(refs_json: &str) -> String {
517    let refs: Vec<String> = match serde_json::from_str(refs_json) {
518        Ok(refs) => refs,
519        Err(error) => return refusal(&format!("refs: {error}")),
520    };
521    SESSION.with(|session| {
522        let session = session.borrow();
523        let Some(session) = session.as_ref() else {
524            return refusal("no assembly session (run a history first)");
525        };
526        let rows: Vec<serde_json::Value> = refs
527            .iter()
528            .map(|name| match mapping::resolve_element(&session.scene, name) {
529                Ok(element) => {
530                    let mut row = local_element_json(&element);
531                    row["ok"] = serde_json::Value::Bool(true);
532                    row
533                }
534                Err(failure) => serde_json::json!({
535                    "ref": name,
536                    "ok": false,
537                    "status": failure.status,
538                    "error": failure.message,
539                }),
540            })
541            .collect();
542        serde_json::Value::Array(rows).to_string()
543    })
544}
545
546/// Every NAMED face and edge of one component, resolved like
547/// [`assembly_resolve_refs_json`] (component-local frames). Entities without
548/// an analytic frame are skipped: a mate could never have referenced them.
549/// An unknown component is an empty list.
550#[wasm_bindgen]
551pub fn assembly_component_entities_json(component_id: &str) -> String {
552    SESSION.with(|session| {
553        let session = session.borrow();
554        let Some(session) = session.as_ref() else {
555            return serde_json::Value::Array(Vec::new()).to_string();
556        };
557        let mut names: Vec<(&'static str, String)> = Vec::new();
558        for (_, handle) in session.scene.component_solids(component_id) {
559            let _ = crate::with_registered_solid_str(handle, |solid| {
560                for face in solid.shells.iter().flat_map(|shell| &shell.faces) {
561                    if let Some(name) = &face.name {
562                        names.push(("face", name.clone()));
563                    }
564                }
565                for edge in &solid.edges {
566                    if let Some(name) = &edge.name {
567                        names.push(("edge", name.clone()));
568                    }
569                }
570                Ok(())
571            });
572        }
573        let rows: Vec<serde_json::Value> = names
574            .into_iter()
575            .filter_map(|(kind, name)| {
576                let element = mapping::resolve_element(&session.scene, &name).ok()?;
577                let mut row = local_element_json(&element);
578                row["kind"] = serde_json::Value::String(kind.into());
579                Some(row)
580            })
581            .collect();
582        serde_json::Value::Array(rows).to_string()
583    })
584}
585
586/// The coarse geometry class of a resolved selection (the overlay row's
587/// `geoms` vocabulary — lowercase `SelectionGeometry` variant names).
588fn geometry_tag(geometry: &crate::SelectionGeometry) -> &'static str {
589    match geometry {
590        crate::SelectionGeometry::Plane { .. } => "plane",
591        crate::SelectionGeometry::Line { .. } => "line",
592        crate::SelectionGeometry::Axis { .. } => "axis",
593        crate::SelectionGeometry::Circle { .. } => "circle",
594        crate::SelectionGeometry::Sphere { .. } => "sphere",
595        crate::SelectionGeometry::Point { .. } => "point",
596    }
597}
598
599// ===========================================================================
600// Mutation surface
601// ===========================================================================
602
603/// Add a constraint of `constraint_type` with the given `inputParams` JSON
604/// (elements etc.; `id` is minted from the type's short name + the persistent
605/// counter when absent). Auto-solves; returns `{id, report}`.
606#[wasm_bindgen]
607pub fn assembly_add_constraint_json(
608    constraint_type: &str,
609    params_json: &str,
610) -> Result<String, JsValue> {
611    assembly_add_constraint_impl(constraint_type, params_json).map_err(|error| JsValue::from_str(&error))
612}
613
614pub fn assembly_add_constraint_impl(constraint_type: &str, params_json: &str) -> Result<String, String> {
615    let def = constraints::constraint_type(constraint_type)
616        .ok_or_else(|| format!("Unknown constraint type: {constraint_type}"))?;
617    let mut params: serde_json::Value = serde_json::from_str(params_json)
618        .map_err(|error| format!("constraint params: {error}"))?;
619    if !params.is_object() {
620        return Err("constraint params must be a JSON object".to_string());
621    }
622    with_session(|session| {
623        let id = match params.get("id").and_then(|value| value.as_str()) {
624            Some(id) if !id.is_empty() => id.to_string(),
625            _ => {
626                session.state.id_counter += 1;
627                let id = format!("{}{}", def.short_name, session.state.id_counter);
628                params
629                    .as_object_mut()
630                    .expect("checked object above")
631                    .insert("id".into(), serde_json::Value::String(id.clone()));
632                id
633            }
634        };
635        session.state.constraints.push(ConstraintEntry {
636            constraint_type: constraint_type.to_string(),
637            input_params: params.clone(),
638            persistent_data: serde_json::Value::Object(serde_json::Map::new()),
639            enabled: true,
640            open: true,
641        });
642        let report = solve_session(session);
643        Ok(serde_json::json!({ "id": id, "report": report }).to_string())
644    })
645}
646
647/// Replace a constraint's `inputParams` (the dialog commit). Auto-solves;
648/// returns the solve report.
649#[wasm_bindgen]
650pub fn assembly_update_constraint_json(id: &str, params_json: &str) -> Result<String, JsValue> {
651    assembly_update_constraint_impl(id, params_json).map_err(|error| JsValue::from_str(&error))
652}
653
654pub fn assembly_update_constraint_impl(id: &str, params_json: &str) -> Result<String, String> {
655    let mut params: serde_json::Value = serde_json::from_str(params_json)
656        .map_err(|error| format!("constraint params: {error}"))?;
657    if !params.is_object() {
658        return Err("constraint params must be a JSON object".to_string());
659    }
660    if params.get("id").and_then(|value| value.as_str()).is_none() {
661        params
662            .as_object_mut()
663            .expect("checked object above")
664            .insert("id".into(), serde_json::Value::String(id.to_string()));
665    }
666    with_session(|session| {
667        let entry = find_entry(session, id)?;
668        entry.input_params = params.clone();
669        Ok(solve_session(session).to_string())
670    })
671}
672
673/// Delete a constraint. Auto-solves; returns the solve report.
674#[wasm_bindgen]
675pub fn assembly_remove_constraint_json(id: &str) -> Result<String, JsValue> {
676    assembly_remove_constraint_impl(id).map_err(|error| JsValue::from_str(&error))
677}
678
679pub fn assembly_remove_constraint_impl(id: &str) -> Result<String, String> {
680    with_session(|session| {
681        let index = find_index(session, id)?;
682        session.state.constraints.remove(index);
683        Ok(solve_session(session).to_string())
684    })
685}
686
687/// Enable/disable a constraint. Auto-solves; returns the solve report.
688#[wasm_bindgen]
689pub fn assembly_set_constraint_enabled_json(id: &str, enabled: bool) -> Result<String, JsValue> {
690    assembly_set_constraint_enabled_impl(id, enabled).map_err(|error| JsValue::from_str(&error))
691}
692
693pub fn assembly_set_constraint_enabled_impl(id: &str, enabled: bool) -> Result<String, String> {
694    with_session(|session| {
695        find_entry(session, id)?.enabled = enabled;
696        Ok(solve_session(session).to_string())
697    })
698}
699
700/// Persist a constraint row's dialog expansion state (view state — no solve).
701#[wasm_bindgen]
702pub fn assembly_set_constraint_open_json(id: &str, open: bool) -> Result<(), JsValue> {
703    assembly_set_constraint_open_impl(id, open).map_err(|error| JsValue::from_str(&error))
704}
705
706pub fn assembly_set_constraint_open_impl(id: &str, open: bool) -> Result<(), String> {
707    with_session(|session| {
708        find_entry(session, id)?.open = open;
709        Ok(())
710    })
711}
712
713/// Reorder a constraint to `index` (clamped). Auto-solves; returns the report.
714#[wasm_bindgen]
715pub fn assembly_move_constraint_json(id: &str, index: usize) -> Result<String, JsValue> {
716    assembly_move_constraint_impl(id, index).map_err(|error| JsValue::from_str(&error))
717}
718
719pub fn assembly_move_constraint_impl(id: &str, index: usize) -> Result<String, String> {
720    with_session(|session| {
721        let from = find_index(session, id)?;
722        let entry = session.state.constraints.remove(from);
723        let to = index.min(session.state.constraints.len());
724        session.state.constraints.insert(to, entry);
725        Ok(solve_session(session).to_string())
726    })
727}
728
729// ===========================================================================
730// Automatic inference (super::infer)
731// ===========================================================================
732
733/// The inferable constraint types, joined to the catalogue: `[{type, label,
734/// icon, longName, detects, defaultOn}]`. Session-free — the dialog can list
735/// what the button offers before any document is open.
736#[wasm_bindgen]
737pub fn assembly_inferable_types_json() -> String {
738    super::infer::inferable_types_json().to_string()
739}
740
741/// SCAN the live scene for constraints its component placement implies, without
742/// creating anything. `options_json` is `super::infer::InferOptions` (`{}` for
743/// the defaults). Never `Err`: a missing session or a bad options object comes
744/// back as `{ok: false, error}` — a `JsValue` error aborts a native build.
745#[wasm_bindgen]
746pub fn assembly_infer_constraints_json(options_json: &str) -> String {
747    match infer_scan(options_json) {
748        Ok(report) => report.to_string(),
749        Err(error) => refusal(&error),
750    }
751}
752
753/// Scan and CREATE: every accepted candidate becomes a constraint, then ONE
754/// solve runs for the whole batch (each inferred constraint is already
755/// satisfied at the pose it was read from, so a healthy assembly does not
756/// move). Returns `{ok, created: [id…], report, solve}`.
757#[wasm_bindgen]
758pub fn assembly_apply_inferred_constraints_json(options_json: &str) -> String {
759    match infer_apply(options_json) {
760        Ok(report) => report.to_string(),
761        Err(error) => refusal(&error),
762    }
763}
764
765fn refusal(error: &str) -> String {
766    serde_json::json!({ "ok": false, "error": error }).to_string()
767}
768
769fn infer_scan(options_json: &str) -> Result<serde_json::Value, String> {
770    let options = super::infer::InferOptions::parse(options_json)?;
771    with_session(|session| Ok(super::infer::scan(&session.state, &session.scene, &options).report()))
772}
773
774fn infer_apply(options_json: &str) -> Result<serde_json::Value, String> {
775    let options = super::infer::InferOptions::parse(options_json)?;
776    with_session(|session| {
777        let scan = super::infer::scan(&session.state, &session.scene, &options);
778        let report = scan.report();
779        let created = super::infer::apply(&mut session.state, &scan.candidates);
780        let solve = if created.is_empty() {
781            serde_json::Value::Null
782        } else {
783            solve_session(session)
784        };
785        Ok(serde_json::json!({
786            "ok": true,
787            "created": created,
788            "report": report,
789            "solve": solve,
790        }))
791    })
792}
793
794/// Manual solve (the panel's Solve button). Returns the solve report.
795#[wasm_bindgen]
796pub fn assembly_run_solve_json() -> Result<String, JsValue> {
797    assembly_run_solve_impl().map_err(|error| JsValue::from_str(&error))
798}
799
800pub fn assembly_run_solve_impl() -> Result<String, String> {
801    with_session(|session| Ok(solve_session(session).to_string()))
802}
803
804/// The live-drag dry run against the session (`lifecycle::drag_solve`).
805/// Request `{poses: {componentId: {translate, rotateEulerDeg}}, unfix?: [id],
806/// reference?: {componentId: pose}, explain?: bool}` — the dragged components at their
807/// dragged poses, and the last good live poses to re-base mechanical mates at. Reply `{ok: true, poses:
808/// {componentId: {translate, rotateEulerDeg}}, blocking: [constraintId]}` for every movable body
809/// (`blocking`: with `explain`, the constraints that would not let the dragged
810/// components reach the drag pose; empty otherwise);
811/// `Err` when the mates cannot hold at this drag. Nothing in the session (the
812/// state, the scene, the pending write-backs, the report) changes.
813#[wasm_bindgen]
814pub fn assembly_drag_solve_json(request_json: &str) -> Result<String, JsValue> {
815    assembly_drag_solve_impl(request_json).map_err(|error| JsValue::from_str(&error))
816}
817
818pub fn assembly_drag_solve_impl(request_json: &str) -> Result<String, String> {
819    let request: serde_json::Value =
820        serde_json::from_str(request_json).map_err(|error| format!("drag solve request: {error}"))?;
821    let mut drag = BTreeMap::new();
822    for (id, pose) in request
823        .get("poses")
824        .and_then(|poses| poses.as_object())
825        .ok_or("drag solve request: missing poses")?
826    {
827        drag.insert(id.clone(), mapping::pose_params_to_transform(pose)?);
828    }
829    let mut reference = BTreeMap::new();
830    for (id, pose) in request.get("reference").and_then(|poses| poses.as_object()).into_iter().flatten() {
831        reference.insert(id.clone(), mapping::pose_params_to_transform(pose)?);
832    }
833    let unfix: Vec<String> = request
834        .get("unfix")
835        .and_then(|unfix| unfix.as_array())
836        .map(|ids| ids.iter().filter_map(|id| id.as_str().map(str::to_string)).collect())
837        .unwrap_or_default();
838    let explain = request.get("explain").and_then(|explain| explain.as_bool()).unwrap_or(false);
839    with_session(|session| {
840        let env = Env::build(&session.expressions, &session.configurator)
841            .unwrap_or_else(Env::poisoned);
842        let (poses, blocking) =
843            lifecycle::drag_solve(&session.state, &mut session.scene, &env, &drag, &unfix, &reference, explain)?;
844        let poses: serde_json::Map<String, serde_json::Value> = poses
845            .iter()
846            .map(|(id, transform)| (id.clone(), mapping::transform_to_pose_params(transform)))
847            .collect();
848        Ok(serde_json::json!({ "ok": true, "poses": poses, "blocking": blocking }).to_string())
849    })
850}
851
852/// One frame of a motion study against the session (`lifecycle::frame_solve`).
853/// Request `{overrides: {constraintId: {value?: number, enabled?: bool}},
854/// reference?: {componentId: {translate, rotateEulerDeg}}}`: `value` drives a
855/// distance or angle constraint to it (model units or degrees; an angle may
856/// run past 360), `enabled` switches a constraint off or on for the frame, and
857/// `reference` is the previous frame's poses (the warm start). Reply `{ok:
858/// true, poses: {componentId: {translate, rotateEulerDeg}}}` for every movable
859/// body; `Err` when the mates cannot hold the frame or an override names a
860/// constraint the session lacks or drives one that is not a distance or angle.
861/// Nothing in the session changes.
862#[wasm_bindgen]
863pub fn assembly_frame_solve_json(request_json: &str) -> Result<String, JsValue> {
864    assembly_frame_solve_impl(request_json).map_err(|error| JsValue::from_str(&error))
865}
866
867pub fn assembly_frame_solve_impl(request_json: &str) -> Result<String, String> {
868    let request: serde_json::Value =
869        serde_json::from_str(request_json).map_err(|error| format!("frame solve request: {error}"))?;
870    let mut reference = BTreeMap::new();
871    for (id, pose) in request.get("reference").and_then(|poses| poses.as_object()).into_iter().flatten() {
872        reference.insert(id.clone(), mapping::pose_params_to_transform(pose)?);
873    }
874    with_session(|session| {
875        let mut overrides = BTreeMap::new();
876        for (id, change) in request.get("overrides").and_then(|o| o.as_object()).into_iter().flatten() {
877            let mut entry = lifecycle::FrameOverride::default();
878            if let Some(value) = change.get("value").filter(|v| !v.is_null()) {
879                let value = value.as_f64().ok_or_else(|| format!("frame solve: '{id}' value is not a number"))?;
880                let kind = session
881                    .state
882                    .constraints
883                    .iter()
884                    .find(|c| c.id() == id)
885                    .map(|c| c.constraint_type.as_str())
886                    .ok_or_else(|| format!("frame solve: unknown constraint '{id}'"))?;
887                let param = match kind {
888                    "distance" => "distance",
889                    "angle" => "angle",
890                    other => return Err(format!("frame solve: '{id}' is a {other} constraint; a motor drives a distance or angle")),
891                };
892                entry.value = Some((param.to_string(), value));
893            }
894            entry.enabled = change.get("enabled").and_then(|e| e.as_bool());
895            overrides.insert(id.clone(), entry);
896        }
897        let env = Env::build(&session.expressions, &session.configurator).unwrap_or_else(Env::poisoned);
898        let poses = lifecycle::frame_solve(&session.state, &mut session.scene, &env, &overrides, &reference)?;
899        let poses: serde_json::Map<String, serde_json::Value> = poses
900            .iter()
901            .map(|(id, transform)| (id.clone(), mapping::transform_to_pose_params(transform)))
902            .collect();
903        Ok(serde_json::json!({ "ok": true, "poses": poses }).to_string())
904    })
905}
906
907/// Refresh the mechanical mates' stored references at the released poses of
908/// a live drag (`lifecycle::rebase_mechanical`). Request `{poses:
909/// {componentId: {translate, rotateEulerDeg}}}`. Reply: the updated
910/// `assembly` block JSON when a mechanical mate was refreshed, else `null`.
911/// The caller writes the block in the SAME undo step as the poses, so the
912/// next run maps a multi-turn drag from where it ended.
913pub fn assembly_rebase_mechanical_impl(request_json: &str) -> Result<String, String> {
914    let request: serde_json::Value =
915        serde_json::from_str(request_json).map_err(|error| format!("rebase request: {error}"))?;
916    let mut poses = BTreeMap::new();
917    for (id, pose) in request.get("poses").and_then(|poses| poses.as_object()).into_iter().flatten() {
918        poses.insert(id.clone(), mapping::pose_params_to_transform(pose)?);
919    }
920    with_session(|session| {
921        let env = Env::build(&session.expressions, &session.configurator).unwrap_or_else(Env::poisoned);
922        let changed = lifecycle::rebase_mechanical(&mut session.state, &mut session.scene, &env, &poses);
923        Ok(if changed {
924            serde_json::to_string(&session.state).map_err(|error| error.to_string())?
925        } else {
926            "null".to_string()
927        })
928    })
929}
930
931/// What **Flip** would do to constraint `id`, read from the live session:
932/// `{ok: true, patch, seed, elements: [{component, fixed, anchor, direction,
933/// geom}], value, angleAxis}` (world frame, pick order), or `{ok: false,
934/// reason}` for a type with no other solution. Read-only: the caller writes the
935/// patch and the seeded pose as one edit (`constraints::flip_rule`).
936#[wasm_bindgen]
937pub fn assembly_flip_plan_json(id: &str) -> String {
938    SESSION.with(|session| {
939        let session = session.borrow();
940        let Some(session) = session.as_ref() else {
941            return refusal("no assembly session (run a history first)");
942        };
943        let Some(entry) = session.state.constraints.iter().find(|entry| entry.id() == id) else {
944            return refusal(&format!("unknown constraint '{id}'"));
945        };
946        let env = Env::build(&session.expressions, &session.configurator).unwrap_or_else(Env::poisoned);
947        let row = overlay_row(entry, &session.scene, &env);
948        let geoms: Vec<&str> = row["geoms"].as_array().into_iter().flatten().filter_map(|g| g.as_str()).collect();
949        let (patch, seed) = match constraints::flip_rule(&entry.constraint_type, &entry.input_params, &geoms) {
950            Ok(rule) => rule,
951            Err(reason) => return serde_json::json!({ "ok": false, "reason": reason }).to_string(),
952        };
953        let elements: Vec<serde_json::Value> = entry
954            .elements()
955            .iter()
956            .enumerate()
957            .map(|(index, name)| {
958                let component = mapping::resolve_element(&session.scene, name).map(|element| element.component).unwrap_or_default();
959                serde_json::json!({
960                    "component": component,
961                    "fixed": session.scene.component_fixed(&component).unwrap_or(false),
962                    "anchor": row["anchors"].get(index).cloned().unwrap_or(serde_json::Value::Null),
963                    "direction": row["directions"].get(index).cloned().unwrap_or(serde_json::Value::Null),
964                    "geom": row["geoms"].get(index).cloned().unwrap_or(serde_json::Value::Null),
965                })
966            })
967            .collect();
968        serde_json::json!({
969            "ok": true,
970            "patch": patch,
971            "seed": seed,
972            "elements": elements,
973            "value": row["value"],
974            "angleAxis": row["angleAxis"],
975        })
976        .to_string()
977    })
978}
979
980fn find_index(session: &Session, id: &str) -> Result<usize, String> {
981    session
982        .state
983        .constraints
984        .iter()
985        .position(|entry| entry.id() == id)
986        .ok_or_else(|| format!("unknown constraint '{id}'"))
987}
988
989fn find_entry<'a>(session: &'a mut Session, id: &str) -> Result<&'a mut ConstraintEntry, String> {
990    let index = find_index(session, id)?;
991    Ok(&mut session.state.constraints[index])
992}