Skip to main content

brep_kernel/solvers/assembly_solver/
decompose.rs

1use super::*;
2
3// ---------------------------------------------------------------------------
4// §7.5–7.6 decomposition pipeline
5// ---------------------------------------------------------------------------
6
7/// Deterministic union-find (smallest index wins as representative).
8struct UnionFind {
9    parent: Vec<usize>,
10}
11
12impl UnionFind {
13    fn new(n: usize) -> Self {
14        Self {
15            parent: (0..n).collect(),
16        }
17    }
18
19    fn find(&mut self, x: usize) -> usize {
20        let mut root = x;
21        while self.parent[root] != root {
22            root = self.parent[root];
23        }
24        let mut cur = x;
25        while self.parent[cur] != root {
26            let next = self.parent[cur];
27            self.parent[cur] = root;
28            cur = next;
29        }
30        root
31    }
32
33    fn union(&mut self, a: usize, b: usize) {
34        let (ra, rb) = (self.find(a), self.find(b));
35        if ra != rb {
36            let (lo, hi) = if ra < rb { (ra, rb) } else { (rb, ra) };
37            self.parent[hi] = lo;
38        }
39    }
40}
41
42struct DecompOutcome {
43    steps: Vec<SolveStepReport>,
44    run: LmRun,
45}
46
47/// §7.6 sequential peeling + §7.5 rigid-cluster contraction, with a
48/// monolithic fallback for the genuinely coupled remainder. Solves in place:
49/// `poses` starts at the initial guesses and ends at the decomposed solution.
50fn solve_decomposed(
51    bodies: &[AssemblyBody],
52    mates: &[AssemblyMate],
53    prep: &Prepared,
54    poses: &mut Vec<Pose>,
55    max_iterations: usize,
56) -> Result<DecompOutcome, String> {
57    let nb = bodies.len();
58    let mut solved: Vec<bool> = bodies.iter().map(|b| b.fixed).collect();
59
60    // Atom indices per mate; incident mate indices per body.
61    let mut mate_atoms: Vec<Vec<usize>> = vec![Vec::new(); mates.len()];
62    for (ai, &mi) in prep.atom_mate.iter().enumerate() {
63        mate_atoms[mi].push(ai);
64    }
65    let mut incident: Vec<Vec<usize>> = vec![Vec::new(); nb];
66    for (mi, mate) in mates.iter().enumerate() {
67        incident[mate.body_a].push(mi);
68        incident[mate.body_b].push(mi);
69    }
70
71    // Live decomposition nodes: a singleton per movable body, contracted into
72    // clusters as §7.5 verifies them. `node_of[b]` is b's live node, if any.
73    let mut nodes: Vec<Option<Vec<usize>>> = Vec::new();
74    let mut node_of: Vec<Option<usize>> = vec![None; nb];
75    for &b in &prep.movable {
76        node_of[b] = Some(nodes.len());
77        nodes.push(Some(vec![b]));
78    }
79
80    let ids = |members: &[usize]| -> Vec<String> {
81        members.iter().map(|&b| bodies[b].id.clone()).collect()
82    };
83    let atoms_of = |mate_set: &[usize]| -> Vec<Atom> {
84        mate_set
85            .iter()
86            .flat_map(|&mi| mate_atoms[mi].iter().map(|&ai| prep.atoms[ai]))
87            .collect()
88    };
89
90    let mut steps: Vec<SolveStepReport> = Vec::new();
91    let mut run = LmRun::default();
92
93    loop {
94        // ---- §7.6 sequential peeling ------------------------------------
95        // Repeatedly solve the first node (creation order = ascending body
96        // index) that is READY: either every incident mate references a
97        // solved body (nothing is deferred; free DOF simply keep the guess),
98        // or the mates to already-solved bodies alone FULLY constrain the
99        // node (Jacobian rank 6 over its rigid DOF), in which case the mates
100        // to not-yet-solved bodies are safely deferred — they are enforced
101        // later by moving those bodies. Each pick is a small 6-DOF LM instead
102        // of a slice of the monolithic one.
103        let mut peeled = false;
104        loop {
105            let mut pick: Option<(usize, Vec<usize>)> = None;
106            for (ni, node) in nodes.iter().enumerate() {
107                let Some(members) = node else { continue };
108                // Partition this node's external mates by the other side.
109                let mut to_solved: Vec<usize> = Vec::new();
110                let mut deferred = false;
111                for &b in members {
112                    for &mi in &incident[b] {
113                        let other = if mates[mi].body_a == b {
114                            mates[mi].body_b
115                        } else {
116                            mates[mi].body_a
117                        };
118                        if node_of[other] == Some(ni) {
119                            continue; // Internal to the node.
120                        }
121                        if solved[other] {
122                            to_solved.push(mi);
123                        } else {
124                            deferred = true;
125                        }
126                    }
127                }
128                to_solved.sort_unstable();
129                to_solved.dedup();
130                let ready = if !deferred {
131                    true
132                } else if to_solved.is_empty() {
133                    false
134                } else {
135                    // Fully constrained by the solved side alone?
136                    let atoms = atoms_of(&to_solved);
137                    let m: usize = atoms.iter().map(Atom::rows).sum();
138                    let groups = vec![SolveGroup {
139                        members: members.clone(),
140                    }];
141                    let jac = finite_diff_jacobian(
142                        &atoms,
143                        poses,
144                        &groups,
145                        &column_steps(1, prep.scale),
146                        m,
147                    );
148                    run.rows += 2 * 6 * m;
149                    numerical_rank(jac, m, 6) == 6
150                };
151                if ready {
152                    pick = Some((ni, to_solved));
153                    break;
154                }
155            }
156            let Some((ni, mate_set)) = pick else { break };
157            let members = nodes[ni].take().expect("picked node is live");
158            let atoms = atoms_of(&mate_set);
159            let (method, iterations) = if atoms.is_empty() {
160                ("unconstrained", 0)
161            } else {
162                let groups = vec![SolveGroup {
163                    members: members.clone(),
164                }];
165                let sub = lm_core(
166                    &atoms,
167                    poses,
168                    &groups,
169                    prep.scale,
170                    prep.tight,
171                    max_iterations,
172                )?;
173                let iters = sub.iterations;
174                run.absorb(sub);
175                let method = if members.len() == 1 {
176                    "sequential"
177                } else {
178                    "cluster_sequential"
179                };
180                (method, iters)
181            };
182            steps.push(SolveStepReport {
183                bodies: ids(&members),
184                method: method.into(),
185                iterations,
186            });
187            for &b in &members {
188                solved[b] = true;
189                node_of[b] = None;
190            }
191            peeled = true;
192        }
193        if nodes.iter().all(|n| n.is_none()) {
194            break; // Everything solved.
195        }
196
197        // ---- §7.5 rigid-cluster detection -------------------------------
198        // Peeling stalled: look for pairs of unsolved lone bodies whose
199        // mutual mates fully bind them (relative Jacobian rank 6 at the
200        // current poses), union-find them into candidate clusters, solve each
201        // cluster internally (anchor pinned) and verify rigidity by the
202        // internal-mate Jacobian having rank exactly 6·(k−1) at the internal
203        // solution. Verified clusters contract into one rigid node.
204        let mut contracted = false;
205        let lone = |b: usize, nodes: &[Option<Vec<usize>>], node_of: &[Option<usize>]| -> bool {
206            node_of[b]
207                .and_then(|ni| nodes[ni].as_ref())
208                .is_some_and(|members| members.len() == 1)
209        };
210        let mut pair_mates: std::collections::BTreeMap<(usize, usize), Vec<usize>> =
211            std::collections::BTreeMap::new();
212        for (mi, mate) in mates.iter().enumerate() {
213            let (a, b) = (mate.body_a, mate.body_b);
214            if !solved[a] && !solved[b] && lone(a, &nodes, &node_of) && lone(b, &nodes, &node_of) {
215                pair_mates.entry((a.min(b), a.max(b))).or_default().push(mi);
216            }
217        }
218        let mut uf = UnionFind::new(nb);
219        let mut any_binding = false;
220        for (&(a, b), pm) in &pair_mates {
221            let atoms = atoms_of(pm);
222            let m: usize = atoms.iter().map(Atom::rows).sum();
223            let groups = singleton_groups(&[b]);
224            let jac = finite_diff_jacobian(&atoms, poses, &groups, &column_steps(1, prep.scale), m);
225            run.rows += 2 * 6 * m;
226            if numerical_rank(jac, m, 6) == 6 {
227                uf.union(a, b);
228                any_binding = true;
229            }
230        }
231        if any_binding {
232            let mut components: std::collections::BTreeMap<usize, Vec<usize>> =
233                std::collections::BTreeMap::new();
234            for &(a, b) in pair_mates.keys() {
235                for body in [a, b] {
236                    let root = uf.find(body);
237                    let entry = components.entry(root).or_default();
238                    if !entry.contains(&body) {
239                        entry.push(body);
240                    }
241                }
242            }
243            for (_, mut comp) in components {
244                if comp.len() < 2 {
245                    continue;
246                }
247                comp.sort_unstable();
248                // Internal solve: pin the anchor member, move the rest.
249                let saved: Vec<Pose> = comp.iter().map(|&b| poses[b]).collect();
250                let mut internal: Vec<usize> = comp
251                    .iter()
252                    .flat_map(|&b| incident[b].iter().copied())
253                    .filter(|&mi| {
254                        comp.contains(&mates[mi].body_a) && comp.contains(&mates[mi].body_b)
255                    })
256                    .collect();
257                internal.sort_unstable();
258                internal.dedup();
259                let atoms = atoms_of(&internal);
260                let groups = singleton_groups(&comp[1..]);
261                let sub = lm_core(
262                    &atoms,
263                    poses,
264                    &groups,
265                    prep.scale,
266                    prep.tight,
267                    max_iterations,
268                )?;
269                let sub_iters = sub.iterations;
270                run.absorb(sub);
271                // Verify §7.5 rigidity at the internal solution.
272                let m_int: usize = atoms.iter().map(Atom::rows).sum();
273                let all_groups = singleton_groups(&comp);
274                let mut scratch: Vec<Pose> = Vec::new();
275                let mut r_int: Vec<f64> = Vec::new();
276                eval_residuals(&atoms, poses, &[], &[], &mut scratch, &mut r_int);
277                let max_int = r_int.iter().fold(0.0f64, |acc, &x| acc.max(x.abs()));
278                let jac = finite_diff_jacobian(
279                    &atoms,
280                    poses,
281                    &all_groups,
282                    &column_steps(comp.len(), prep.scale),
283                    m_int,
284                );
285                let rank = numerical_rank(jac, m_int, 6 * comp.len());
286                run.rows += m_int + 2 * 6 * comp.len() * m_int;
287                if max_int <= prep.tight && rank == 6 * (comp.len() - 1) {
288                    steps.push(SolveStepReport {
289                        bodies: ids(&comp),
290                        method: "cluster_internal".into(),
291                        iterations: sub_iters,
292                    });
293                    // Contract: one live node owning every member.
294                    let cluster = nodes.len();
295                    for &b in &comp {
296                        if let Some(old) = node_of[b] {
297                            nodes[old] = None;
298                        }
299                        node_of[b] = Some(cluster);
300                    }
301                    nodes.push(Some(comp));
302                    contracted = true;
303                } else {
304                    // Not actually rigid: restore and leave for the fallback.
305                    for (&b, pose) in comp.iter().zip(&saved) {
306                        poses[b] = *pose;
307                    }
308                }
309            }
310        }
311        if peeled || contracted {
312            continue;
313        }
314
315        // ---- genuinely coupled remainder: monolithic fallback -----------
316        // Neither peelable nor clusterable (a coupled cycle of mates): solve
317        // everything still unsolved with one monolithic LM over exactly the
318        // remaining bodies and every not-yet-applied mate.
319        let remaining: Vec<usize> = (0..nb).filter(|&b| node_of[b].is_some()).collect();
320        let mate_set: Vec<usize> = (0..mates.len())
321            .filter(|&mi| !solved[mates[mi].body_a] || !solved[mates[mi].body_b])
322            .collect();
323        let atoms = atoms_of(&mate_set);
324        let groups = singleton_groups(&remaining);
325        let sub = lm_core(
326            &atoms,
327            poses,
328            &groups,
329            prep.scale,
330            prep.tight,
331            max_iterations,
332        )?;
333        let iters = sub.iterations;
334        run.absorb(sub);
335        steps.push(SolveStepReport {
336            bodies: ids(&remaining),
337            method: "monolithic_fallback".into(),
338            iterations: iters,
339        });
340        for node in nodes.iter_mut() {
341            *node = None;
342        }
343        for &b in &remaining {
344            solved[b] = true;
345            node_of[b] = None;
346        }
347        break;
348    }
349
350    Ok(DecompOutcome { steps, run })
351}
352
353// ---------------------------------------------------------------------------
354// Solver entry point
355// ---------------------------------------------------------------------------
356
357/// Solve the assembly mates in least squares from the bodies' initial poses.
358///
359/// Grounded bodies never move; free degrees of freedom of movable bodies stay
360/// where the initial guess put them (minimum-norm LM steps). By default
361/// (`SolveStrategy::Auto`) the solve is decomposed per §7.5–7.6 — sequential
362/// peeling from the grounded bodies plus rigid-cluster contraction, with a
363/// monolithic fallback for coupled cycles — and agrees with the monolithic
364/// strategy within the solve tolerance on well-posed inputs. Returns `Err`
365/// when the mates cannot all be satisfied (conflict) or the solve fails to
366/// converge, naming the worst-residual mates.
367pub fn solve_assembly(
368    bodies: &[AssemblyBody],
369    mates: &[AssemblyMate],
370    options: &AssemblySolveOptions,
371) -> Result<AssemblySolution, String> {
372    if !(options.tolerance.is_finite() && options.tolerance > 0.0) {
373        return Err("options.tolerance must be finite and positive".into());
374    }
375    let prep = prepare(bodies, mates, options)?;
376    let movable_ids: Vec<String> = prep.movable.iter().map(|&b| bodies[b].id.clone()).collect();
377
378    let run_monolithic = |poses: &mut Vec<Pose>| -> Result<LmRun, String> {
379        let groups = singleton_groups(&prep.movable);
380        lm_core(
381            &prep.atoms,
382            poses,
383            &groups,
384            prep.scale,
385            prep.tight,
386            options.max_iterations,
387        )
388    };
389
390    if options.strategy == SolveStrategy::Monolithic {
391        let mut poses = prep.base.clone();
392        let run = run_monolithic(&mut poses)?;
393        let steps = vec![SolveStepReport {
394            bodies: movable_ids,
395            method: "monolithic".into(),
396            iterations: run.iterations,
397        }];
398        return finalize(bodies, mates, &prep, &poses, run, "monolithic", steps);
399    }
400
401    // Auto / Decomposed: §7.5–7.6 pipeline, then a global acceptance check.
402    let mut poses = prep.base.clone();
403    let outcome = solve_decomposed(bodies, mates, &prep, &mut poses, options.max_iterations)?;
404    let mut run = outcome.run;
405
406    let mut scratch: Vec<Pose> = Vec::new();
407    let mut r: Vec<f64> = Vec::new();
408    eval_residuals(&prep.atoms, &poses, &[], &[], &mut scratch, &mut r);
409    run.rows += prep.m;
410    let max_res = r.iter().fold(0.0f64, |acc, &x| acc.max(x.abs()));
411    if max_res.is_finite() && max_res <= prep.tight {
412        return finalize(
413            bodies,
414            mates,
415            &prep,
416            &poses,
417            run,
418            "decomposed",
419            outcome.steps,
420        );
421    }
422
423    // The decomposed result missed the global tolerance (an inconsistency
424    // spanning steps, or an ill-posed input): redo everything monolithically
425    // from the ORIGINAL poses so the decomposed strategies are never worse
426    // than `Monolithic`. `finalize` raises the v1-style error if even that
427    // does not converge.
428    let mut poses = prep.base.clone();
429    let sub = run_monolithic(&mut poses)?;
430    let iters = sub.iterations;
431    run.absorb(sub);
432    let steps = vec![SolveStepReport {
433        bodies: movable_ids,
434        method: "monolithic_fallback".into(),
435        iterations: iters,
436    }];
437    finalize(
438        bodies,
439        mates,
440        &prep,
441        &poses,
442        run,
443        "monolithic_fallback",
444        steps,
445    )
446}