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