Skip to main content

brep_kernel/solvers/assembly_solver/
decompose.rs

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