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
42/// Every body a mate reaches: its two sides and, for a symmetric mate, the
43/// mirror's body when that is a third one.
44fn mate_bodies(mate: &AssemblyMate) -> impl Iterator<Item = usize> {
45    let (a, b) = (mate.body_a, mate.body_b);
46    let third = mate.body_c.filter(|&c| c != a && c != b);
47    [a, b].into_iter().chain(third)
48}
49
50struct DecompOutcome {
51    steps: Vec<SolveStepReport>,
52    run: LmRun,
53}
54
55/// §7.6 sequential peeling + §7.5 rigid-cluster contraction, with a
56/// monolithic fallback for the genuinely coupled remainder. Solves in place:
57/// `poses` starts at the initial guesses and ends at the decomposed solution.
58fn solve_decomposed(
59    bodies: &[AssemblyBody],
60    mates: &[AssemblyMate],
61    prep: &Prepared,
62    poses: &mut Vec<Pose>,
63    max_iterations: usize,
64) -> Result<DecompOutcome, String> {
65    let nb = bodies.len();
66    let mut solved: Vec<bool> = bodies.iter().map(|b| b.fixed).collect();
67
68    // Atom indices per mate; incident mate indices per body.
69    let mut mate_atoms: Vec<Vec<usize>> = vec![Vec::new(); mates.len()];
70    for (ai, &mi) in prep.atom_mate.iter().enumerate() {
71        mate_atoms[mi].push(ai);
72    }
73    let mut incident: Vec<Vec<usize>> = vec![Vec::new(); nb];
74    for (mi, mate) in mates.iter().enumerate() {
75        for body in mate_bodies(mate) {
76            incident[body].push(mi);
77        }
78    }
79
80    // Live decomposition nodes: a singleton per movable body, contracted into
81    // clusters as §7.5 verifies them. `node_of[b]` is b's live node, if any.
82    let mut nodes: Vec<Option<Vec<usize>>> = Vec::new();
83    let mut node_of: Vec<Option<usize>> = vec![None; nb];
84    for &b in &prep.movable {
85        node_of[b] = Some(nodes.len());
86        nodes.push(Some(vec![b]));
87    }
88
89    let ids = |members: &[usize]| -> Vec<String> {
90        members.iter().map(|&b| bodies[b].id.clone()).collect()
91    };
92    let atoms_of = |mate_set: &[usize]| -> Vec<Atom> {
93        mate_set
94            .iter()
95            .flat_map(|&mi| mate_atoms[mi].iter().map(|&ai| prep.atoms[ai].clone()))
96            .collect()
97    };
98
99    let mut steps: Vec<SolveStepReport> = Vec::new();
100    let mut run = LmRun::default();
101
102    loop {
103        // ---- §7.6 sequential peeling ------------------------------------
104        // Repeatedly solve the first node (creation order = ascending body
105        // index) that is READY: either every incident mate references a
106        // solved body (nothing is deferred; free DOF simply keep the guess),
107        // or the mates to already-solved bodies alone FULLY constrain the
108        // node (Jacobian rank 6 over its rigid DOF), in which case the mates
109        // to not-yet-solved bodies are safely deferred — they are enforced
110        // later by moving those bodies. Each pick is a small 6-DOF LM instead
111        // of a slice of the monolithic one.
112        let mut peeled = false;
113        loop {
114            let mut pick: Option<(usize, Vec<usize>)> = None;
115            for (ni, node) in nodes.iter().enumerate() {
116                let Some(members) = node else { continue };
117                // Partition this node's external mates by the other side.
118                let mut to_solved: Vec<usize> = Vec::new();
119                let mut deferred = false;
120                // A mate's other bodies are every body it reaches outside
121                // this node (a symmetric mate's mirror included): it counts
122                // toward the solved side only when ALL of them are solved.
123                for &b in members {
124                    for &mi in &incident[b] {
125                        let mut outside = mate_bodies(&mates[mi]).filter(|&other| node_of[other] != Some(ni)).peekable();
126                        if outside.peek().is_none() {
127                            continue; // Internal to the node.
128                        }
129                        if outside.all(|other| solved[other]) {
130                            to_solved.push(mi);
131                        } else {
132                            deferred = true;
133                        }
134                    }
135                }
136                to_solved.sort_unstable();
137                to_solved.dedup();
138                let ready = if !deferred {
139                    true
140                } else if to_solved.is_empty() {
141                    false
142                } else {
143                    // Fully constrained by the solved side alone?
144                    let atoms = atoms_of(&to_solved);
145                    let m: usize = atoms.iter().map(Atom::rows).sum();
146                    let groups = vec![SolveGroup {
147                        members: members.clone(),
148                    }];
149                    let jac = finite_diff_jacobian(
150                        &atoms,
151                        poses,
152                        &groups,
153                        &column_steps(1, prep.scale),
154                        m,
155                    );
156                    run.rows += 2 * 6 * m;
157                    numerical_rank(jac, m, 6) == 6
158                };
159                if ready {
160                    pick = Some((ni, to_solved));
161                    break;
162                }
163            }
164            let Some((ni, mate_set)) = pick else { break };
165            let members = nodes[ni].take().expect("picked node is live");
166            let atoms = atoms_of(&mate_set);
167            let (method, iterations) = if atoms.is_empty() {
168                ("unconstrained", 0)
169            } else {
170                let groups = vec![SolveGroup {
171                    members: members.clone(),
172                }];
173                let sub = lm_core(
174                    &atoms,
175                    poses,
176                    &groups,
177                    prep.scale,
178                    prep.tight,
179                    max_iterations,
180                    prep.linear,
181                )?;
182                let iters = sub.iterations;
183                run.absorb(sub);
184                let method = if members.len() == 1 {
185                    "sequential"
186                } else {
187                    "cluster_sequential"
188                };
189                (method, iters)
190            };
191            steps.push(SolveStepReport {
192                bodies: ids(&members),
193                method: method.into(),
194                iterations,
195            });
196            for &b in &members {
197                solved[b] = true;
198                node_of[b] = None;
199            }
200            peeled = true;
201        }
202        if nodes.iter().all(|n| n.is_none()) {
203            break; // Everything solved.
204        }
205
206        // ---- §7.5 rigid-cluster detection -------------------------------
207        // Peeling stalled: look for pairs of unsolved lone bodies whose
208        // mutual mates fully bind them (relative Jacobian rank 6 at the
209        // current poses), union-find them into candidate clusters, solve each
210        // cluster internally (anchor pinned) and verify rigidity by the
211        // internal-mate Jacobian having rank exactly 6·(k−1) at the internal
212        // solution. Verified clusters contract into one rigid node.
213        let mut contracted = false;
214        let lone = |b: usize, nodes: &[Option<Vec<usize>>], node_of: &[Option<usize>]| -> bool {
215            node_of[b]
216                .and_then(|ni| nodes[ni].as_ref())
217                .is_some_and(|members| members.len() == 1)
218        };
219        let mut pair_mates: std::collections::BTreeMap<(usize, usize), Vec<usize>> =
220            std::collections::BTreeMap::new();
221        for (mi, mate) in mates.iter().enumerate() {
222            let (a, b) = (mate.body_a, mate.body_b);
223            // Pairs only: a mate reaching a third body binds no PAIR.
224            if mate_bodies(mate).count() > 2 {
225                continue;
226            }
227            if !solved[a] && !solved[b] && lone(a, &nodes, &node_of) && lone(b, &nodes, &node_of) {
228                pair_mates.entry((a.min(b), a.max(b))).or_default().push(mi);
229            }
230        }
231        let mut uf = UnionFind::new(nb);
232        let mut any_binding = false;
233        for (&(a, b), pm) in &pair_mates {
234            let atoms = atoms_of(pm);
235            let m: usize = atoms.iter().map(Atom::rows).sum();
236            let groups = singleton_groups(&[b]);
237            let jac = finite_diff_jacobian(&atoms, poses, &groups, &column_steps(1, prep.scale), m);
238            run.rows += 2 * 6 * m;
239            if numerical_rank(jac, m, 6) == 6 {
240                uf.union(a, b);
241                any_binding = true;
242            }
243        }
244        if any_binding {
245            let mut components: std::collections::BTreeMap<usize, Vec<usize>> =
246                std::collections::BTreeMap::new();
247            for &(a, b) in pair_mates.keys() {
248                for body in [a, b] {
249                    let root = uf.find(body);
250                    let entry = components.entry(root).or_default();
251                    if !entry.contains(&body) {
252                        entry.push(body);
253                    }
254                }
255            }
256            for (_, mut comp) in components {
257                if comp.len() < 2 {
258                    continue;
259                }
260                comp.sort_unstable();
261                // Internal solve: pin the anchor member, move the rest.
262                let saved: Vec<Pose> = comp.iter().map(|&b| poses[b]).collect();
263                let mut internal: Vec<usize> = comp
264                    .iter()
265                    .flat_map(|&b| incident[b].iter().copied())
266                    .filter(|&mi| mate_bodies(&mates[mi]).all(|body| comp.contains(&body)))
267                    .collect();
268                internal.sort_unstable();
269                internal.dedup();
270                let atoms = atoms_of(&internal);
271                let groups = singleton_groups(&comp[1..]);
272                let sub = lm_core(
273                    &atoms,
274                    poses,
275                    &groups,
276                    prep.scale,
277                    prep.tight,
278                    max_iterations,
279                    prep.linear,
280                )?;
281                let sub_iters = sub.iterations;
282                run.absorb(sub);
283                // Verify §7.5 rigidity at the internal solution.
284                let m_int: usize = atoms.iter().map(Atom::rows).sum();
285                let all_groups = singleton_groups(&comp);
286                let mut scratch: Vec<Pose> = Vec::new();
287                let mut r_int: Vec<f64> = Vec::new();
288                eval_residuals(&atoms, poses, &[], &[], &mut scratch, &mut r_int);
289                let max_int = r_int.iter().fold(0.0f64, |acc, &x| acc.max(x.abs()));
290                let jac = finite_diff_jacobian(
291                    &atoms,
292                    poses,
293                    &all_groups,
294                    &column_steps(comp.len(), prep.scale),
295                    m_int,
296                );
297                let rank = numerical_rank(jac, m_int, 6 * comp.len());
298                run.rows += m_int + 2 * 6 * comp.len() * m_int;
299                if max_int <= prep.tight && rank == 6 * (comp.len() - 1) {
300                    steps.push(SolveStepReport {
301                        bodies: ids(&comp),
302                        method: "cluster_internal".into(),
303                        iterations: sub_iters,
304                    });
305                    // Contract: one live node owning every member.
306                    let cluster = nodes.len();
307                    for &b in &comp {
308                        if let Some(old) = node_of[b] {
309                            nodes[old] = None;
310                        }
311                        node_of[b] = Some(cluster);
312                    }
313                    nodes.push(Some(comp));
314                    contracted = true;
315                } else {
316                    // Not actually rigid: restore and leave for the fallback.
317                    for (&b, pose) in comp.iter().zip(&saved) {
318                        poses[b] = *pose;
319                    }
320                }
321            }
322        }
323        if peeled || contracted {
324            continue;
325        }
326
327        // ---- genuinely coupled remainder: monolithic fallback -----------
328        // Neither peelable nor clusterable (a coupled cycle of mates): solve
329        // everything still unsolved with one monolithic LM over exactly the
330        // remaining bodies and every not-yet-applied mate.
331        let remaining: Vec<usize> = (0..nb).filter(|&b| node_of[b].is_some()).collect();
332        let mate_set: Vec<usize> = (0..mates.len())
333            .filter(|&mi| mate_bodies(&mates[mi]).any(|body| !solved[body]))
334            .collect();
335        let atoms = atoms_of(&mate_set);
336        let groups = singleton_groups(&remaining);
337        let sub = lm_core(
338            &atoms,
339            poses,
340            &groups,
341            prep.scale,
342            prep.tight,
343            max_iterations,
344            prep.linear,
345        )?;
346        let iters = sub.iterations;
347        run.absorb(sub);
348        steps.push(SolveStepReport {
349            bodies: ids(&remaining),
350            method: "monolithic_fallback".into(),
351            iterations: iters,
352        });
353        for node in nodes.iter_mut() {
354            *node = None;
355        }
356        for &b in &remaining {
357            solved[b] = true;
358            node_of[b] = None;
359        }
360        break;
361    }
362
363    Ok(DecompOutcome { steps, run })
364}
365
366// ---------------------------------------------------------------------------
367// Solver entry point
368// ---------------------------------------------------------------------------
369
370/// Solve the assembly mates in least squares from the bodies' initial poses.
371///
372/// Grounded bodies never move; free degrees of freedom of movable bodies stay
373/// where the initial guess put them (minimum-norm LM steps). By default
374/// (`SolveStrategy::Auto`) the solve is decomposed per §7.5–7.6 — sequential
375/// peeling from the grounded bodies plus rigid-cluster contraction, with a
376/// monolithic fallback for coupled cycles — and agrees with the monolithic
377/// strategy within the solve tolerance on well-posed inputs. Returns `Err`
378/// when the mates cannot all be satisfied (conflict) or the solve fails to
379/// converge, naming the worst-residual mates.
380pub fn solve_assembly(
381    bodies: &[AssemblyBody],
382    mates: &[AssemblyMate],
383    options: &AssemblySolveOptions,
384) -> Result<AssemblySolution, String> {
385    let result = solve_with(bodies, mates, options);
386    // The sparse/dense A/B over the test corpus: a test run with
387    // BREP_ASSEMBLY_AB set re-solves every sparse solve densely and compares.
388    // Compiled out of every non-test build (`cfg!(test)` is a constant).
389    if cfg!(test) && options.linear_algebra == LinearAlgebra::Sparse && std::env::var_os("BREP_ASSEMBLY_AB").is_some() {
390        let dense = solve_with(bodies, mates, &AssemblySolveOptions { linear_algebra: LinearAlgebra::Dense, ..*options });
391        if let Err(difference) = compare_solutions(&result, &dense) {
392            panic!("sparse/dense A/B: {difference}");
393        }
394    }
395    result
396}
397
398/// The A/B acceptance: both fail, or both succeed with poses within 1e-9 mm
399/// and 1e-9 rad and the same iteration count or the same status. Along a FREE
400/// direction (`dof > 0`) the damped step is the gradient's roundoff divided by
401/// a tiny λ, so the two factorizations may come to rest at different, equally
402/// valid points: there the bar is 1e-6 and the drift is printed (`AB-DRIFT`).
403fn compare_solutions(
404    sparse: &Result<AssemblySolution, String>,
405    dense: &Result<AssemblySolution, String>,
406) -> Result<(), String> {
407    match (sparse, dense) {
408        (Err(_), Err(_)) => Ok(()),
409        (Ok(_), Err(error)) => Err(format!("sparse solved, dense failed: {error}")),
410        (Err(error), Ok(_)) => Err(format!("dense solved, sparse failed: {error}")),
411        (Ok(a), Ok(b)) => {
412            for (pa, pb) in a.poses.iter().zip(&b.poses) {
413                let dt = (0..3).map(|i| (pa.translation[i] - pb.translation[i]).abs()).fold(0.0, f64::max);
414                // The angle of qa·qb⁻¹: twice the norm of its vector part.
415                let (qa, qb) = (pa.rotation, pb.rotation);
416                let rel = quat_mul(qa, [qb[0], -qb[1], -qb[2], -qb[3]]);
417                let angle = 2.0 * (rel[1] * rel[1] + rel[2] * rel[2] + rel[3] * rel[3]).sqrt();
418                let bar = if a.dof > 0 { 1e-6 } else { 1e-9 };
419                if dt > bar || angle > bar {
420                    return Err(format!("body {}: translation {dt:.3e}, rotation {angle:.3e} (dof {})", pa.id, a.dof));
421                }
422                if dt > 1e-9 || angle > 1e-9 {
423                    eprintln!("AB-DRIFT body {} dof {}: translation {dt:.3e}, rotation {angle:.3e}", pa.id, a.dof);
424                }
425            }
426            if a.iterations != b.iterations && a.status != b.status {
427                return Err(format!(
428                    "iterations {} vs {} and status {} vs {}",
429                    a.iterations, b.iterations, a.status, b.status
430                ));
431            }
432            Ok(())
433        }
434    }
435}
436
437fn solve_with(
438    bodies: &[AssemblyBody],
439    mates: &[AssemblyMate],
440    options: &AssemblySolveOptions,
441) -> Result<AssemblySolution, String> {
442    if !(options.tolerance.is_finite() && options.tolerance > 0.0) {
443        return Err("options.tolerance must be finite and positive".into());
444    }
445    let prep = prepare(bodies, mates, options)?;
446    let movable_ids: Vec<String> = prep.movable.iter().map(|&b| bodies[b].id.clone()).collect();
447
448    let run_monolithic = |poses: &mut Vec<Pose>| -> Result<LmRun, String> {
449        let groups = singleton_groups(&prep.movable);
450        lm_core(
451            &prep.atoms,
452            poses,
453            &groups,
454            prep.scale,
455            prep.tight,
456            options.max_iterations,
457            prep.linear,
458        )
459    };
460
461    // A mate reaching a third body (a symmetric mate's mirror) takes part in
462    // the decomposition: peeling waits for all of its bodies, and only the
463    // cluster detection, which binds pairs, leaves it out.
464    if options.strategy == SolveStrategy::Monolithic {
465        let mut poses = prep.base.clone();
466        let run = run_monolithic(&mut poses)?;
467        let steps = vec![SolveStepReport {
468            bodies: movable_ids,
469            method: "monolithic".into(),
470            iterations: run.iterations,
471        }];
472        return finalize(bodies, mates, &prep, &poses, run, "monolithic", steps);
473    }
474
475    // Auto / Decomposed: §7.5–7.6 pipeline, then a global acceptance check.
476    let mut poses = prep.base.clone();
477    let outcome = solve_decomposed(bodies, mates, &prep, &mut poses, options.max_iterations)?;
478    let mut run = outcome.run;
479
480    let mut scratch: Vec<Pose> = Vec::new();
481    let mut r: Vec<f64> = Vec::new();
482    eval_residuals(&prep.atoms, &poses, &[], &[], &mut scratch, &mut r);
483    run.rows += prep.m;
484    let max_res = r.iter().fold(0.0f64, |acc, &x| acc.max(x.abs()));
485    if max_res.is_finite() && max_res <= prep.tight {
486        return finalize(
487            bodies,
488            mates,
489            &prep,
490            &poses,
491            run,
492            "decomposed",
493            outcome.steps,
494        );
495    }
496
497    // The decomposed result missed the global tolerance (an inconsistency
498    // spanning steps, or an ill-posed input): redo everything monolithically
499    // from the ORIGINAL poses so the decomposed strategies are never worse
500    // than `Monolithic`. `finalize` raises the v1-style error if even that
501    // does not converge.
502    let mut poses = prep.base.clone();
503    let sub = run_monolithic(&mut poses)?;
504    let iters = sub.iterations;
505    run.absorb(sub);
506    let mut method = "monolithic_fallback";
507    if let Some((flipped, sub)) = flip_restart(mates, &prep, &poses, &run_monolithic) {
508        run.absorb(sub);
509        poses = flipped;
510        method = "flip_restart";
511    }
512    let steps = vec![SolveStepReport {
513        bodies: movable_ids,
514        method: method.into(),
515        iterations: iters,
516    }];
517    finalize(bodies, mates, &prep, &poses, run, method, steps)
518}
519
520/// The most bodies a failed solve is restarted for, turned over.
521const FLIP_RESTART_BODIES: usize = 4;
522/// Above this many movable bodies a failed solve is not restarted: each
523/// restart is a whole monolithic solve.
524const FLIP_RESTART_MAX_MOVABLE: usize = 64;
525
526/// Two starts the solve cannot leave, because the residual has no gradient
527/// there:
528/// - a mate asked to FLIP (a Touch Align or Concentric reversed, a
529///   Distance's normals opposed) starts exactly antiparallel to its goal
530///   ("did not converge", residual 2);
531/// - a Distance between coaxial cylinders starts where the separation
532///   direction is undefined (residual = the whole distance).
533/// When `solved` still misses the tolerance, each movable body of the
534/// failing mates is restarted from the ORIGINAL poses, turned half a turn
535/// about each world axis through its origin, or nudged a thousandth of the
536/// model's size along each world axis; of the restarts that converge, the
537/// one that moves the bodies least is kept. `None` when none does (a real
538/// conflict), or when the solve converged in the first place.
539fn flip_restart(
540    mates: &[AssemblyMate],
541    prep: &Prepared,
542    solved: &[Pose],
543    run_monolithic: &dyn Fn(&mut Vec<Pose>) -> Result<LmRun, String>,
544) -> Option<(Vec<Pose>, LmRun)> {
545    if prep.movable.len() > FLIP_RESTART_MAX_MOVABLE {
546        return None;
547    }
548    let residuals = |poses: &[Pose]| -> Vec<f64> {
549        let (mut scratch, mut r) = (Vec::new(), Vec::new());
550        eval_residuals(&prep.atoms, poses, &[], &[], &mut scratch, &mut r);
551        r
552    };
553    let converged = |poses: &[Pose]| residuals(poses).iter().all(|x| x.is_finite() && x.abs() <= prep.tight);
554    let r = residuals(solved);
555    let mut per_mate = vec![0.0f64; mates.len()];
556    for (row, &mi) in prep.row_mate.iter().enumerate() {
557        per_mate[mi] = per_mate[mi].max(r[row].abs());
558    }
559    let mut failing: Vec<usize> = (0..mates.len()).filter(|&mi| per_mate[mi] > prep.tight).collect();
560    if failing.is_empty() {
561        return None;
562    }
563    failing.sort_by(|&a, &b| per_mate[b].total_cmp(&per_mate[a]));
564    let mut candidates: Vec<usize> = Vec::new();
565    for &mi in &failing {
566        for body in mate_bodies(&mates[mi]) {
567            if prep.movable.contains(&body) && !candidates.contains(&body) {
568                candidates.push(body);
569            }
570        }
571    }
572    candidates.truncate(FLIP_RESTART_BODIES);
573    let moved = |poses: &[Pose]| -> f64 {
574        poses.iter().zip(&prep.base).map(|(p, b)| p.t.sub(b.t).length()).sum()
575    };
576    for body in candidates {
577        let mut best: Option<(f64, Vec<Pose>, LmRun)> = None;
578        let nudge = 1e-3 * prep.scale.max(1e-9);
579        let starts = [
580            ([0.0, 1.0, 0.0, 0.0], [0.0; 3]),
581            ([0.0, 0.0, 1.0, 0.0], [0.0; 3]),
582            ([0.0, 0.0, 0.0, 1.0], [0.0; 3]),
583            ([1.0, 0.0, 0.0, 0.0], [nudge, 0.0, 0.0]),
584            ([1.0, 0.0, 0.0, 0.0], [0.0, nudge, 0.0]),
585            ([1.0, 0.0, 0.0, 0.0], [0.0, 0.0, nudge]),
586        ];
587        for (turn, shift) in starts {
588            let mut poses = prep.base.clone();
589            poses[body].q = quat_mul(turn, poses[body].q);
590            poses[body].t = poses[body].t.add(crate::Vec3::new(shift[0], shift[1], shift[2]));
591            let Ok(run) = run_monolithic(&mut poses) else { continue };
592            if converged(&poses) {
593                let cost = moved(&poses);
594                if best.as_ref().is_none_or(|(c, _, _)| cost < *c - 1e-9) {
595                    best = Some((cost, poses, run));
596                }
597            }
598        }
599        if let Some((_, poses, run)) = best {
600            return Some((poses, run));
601        }
602    }
603    None
604}