Skip to main content

long_screen/kernels/
pose_graph.rs

1//! Pose-graph relaxation (`src/core/pose-graph.ts::PoseGraph.optimize`/residual computation). A Gauss-Seidel-style
2//! relaxation over a graph handed in as plain arrays: node positions/pinned flags plus a CSR adjacency list the
3//! TS side builds from its `Map` iteration order — this module never sees string ids, only indices, so node
4//! identity/ordering questions stay where the KV layout lives (pose-graph.ts). Alternating forward/reverse
5//! node-visitation order matches `optimize()`'s odd/even iteration parity (`iterations % 2 === 1`).
6
7use crate::kernels::geometry::js_hypot;
8
9pub struct Node {
10    pub x: f64,
11    pub y: f64,
12    pub pinned: bool,
13}
14
15/// One directed edge `node -> other`, `other` already resolved to a node index by the caller. `other < 0` is a
16/// defensive sentinel for "target node not found" (mirrors `this.nodes.get(edge.other)` returning `undefined` in
17/// pose-graph.ts's `optimize()`/`residual` loops): the edge is skipped without stopping the edge loop, exactly as
18/// `if (!other) continue;` does there. In practice every edge's `other` always resolves — nodes are never removed
19/// once hydrated — but the sentinel keeps the two sides' control flow identical rather than assuming it.
20pub struct Edge {
21    pub other: i32,
22    pub dx: f64,
23    pub dy: f64,
24    pub weight: f64,
25}
26
27pub struct Graph {
28    pub nodes: Vec<Node>,
29    /// CSR: `offsets[i]..offsets[i + 1]` indexes into `edges` for node `i`. `offsets.len() == nodes.len() + 1`.
30    pub offsets: Vec<u32>,
31    pub edges: Vec<Edge>,
32}
33
34impl Graph {
35    /// Exact weighted solution of a connected simple cycle with one anchor. A chain plus one
36    /// root-to-last loop has this topology regardless of node/edge order. Reject other graphs and
37    /// solutions outside Huber's quadratic region (6px), leaving the general solver untouched.
38    pub fn solve_cycle(&mut self) -> bool {
39        let n = self.nodes.len();
40        if n < 3 || self.edges.len() != 2 * n || self.nodes.iter().filter(|v| v.pinned).count() != 1
41        {
42            return false;
43        }
44        if self.offsets.windows(2).any(|w| w[1] - w[0] != 2) {
45            return false;
46        }
47        let root = self.nodes.iter().position(|v| v.pinned).unwrap();
48        if !self.nodes[root].x.is_finite() || !self.nodes[root].y.is_finite() {
49            return false;
50        }
51        let mut seen = vec![false; n];
52        let mut path = Vec::with_capacity(n);
53        let (mut current, mut previous) = (root, usize::MAX);
54        let (mut dx, mut dy, mut resistance) = (0.0, 0.0, 0.0);
55        for _ in 0..n {
56            if seen[current] {
57                return false;
58            }
59            seen[current] = true;
60            let start = self.offsets[current] as usize;
61            let candidates = &self.edges[start..start + 2];
62            let Some((offset, edge)) = candidates
63                .iter()
64                .enumerate()
65                .find(|(_, e)| e.other as usize != previous)
66            else {
67                return false;
68            };
69            if edge.other < 0
70                || edge.other as usize >= n
71                || edge.other as usize == current
72                || !edge.dx.is_finite()
73                || !edge.dy.is_finite()
74                || !edge.weight.is_finite()
75                || edge.weight <= 0.0
76            {
77                return false;
78            }
79            let next = edge.other as usize;
80            let mirrors = &self.edges[self.offsets[next] as usize..self.offsets[next + 1] as usize];
81            if mirrors
82                .iter()
83                .filter(|e| {
84                    e.other == current as i32
85                        && e.dx == -edge.dx
86                        && e.dy == -edge.dy
87                        && e.weight == edge.weight
88                })
89                .count()
90                != 1
91            {
92                return false;
93            }
94            path.push((current, start + offset));
95            dx += edge.dx;
96            dy += edge.dy;
97            resistance += 1.0 / edge.weight;
98            previous = current;
99            current = next;
100        }
101        if current != root || !dx.is_finite() || !dy.is_finite() || !resistance.is_finite() {
102            return false;
103        }
104        let mut positions = Vec::with_capacity(n);
105        let (mut x, mut y) = (self.nodes[root].x, self.nodes[root].y);
106        for &(node, index) in &path {
107            positions.push((node, x, y));
108            let e = &self.edges[index];
109            let (rx, ry) = (-dx / (e.weight * resistance), -dy / (e.weight * resistance));
110            if !rx.is_finite() || !ry.is_finite() || js_hypot(rx, ry) > 6.0 {
111                return false;
112            }
113            x += e.dx + rx;
114            y += e.dy + ry;
115            if !x.is_finite() || !y.is_finite() {
116                return false;
117            }
118        }
119        for (i, x, y) in positions {
120            self.nodes[i].x = x;
121            self.nodes[i].y = y;
122        }
123        true
124    }
125
126    /// One Gauss-Seidel sweep over every unpinned node with edges, mutating positions in place exactly as
127    /// `optimize()`'s per-node loop does: a neighbour update earlier in this same sweep is visible to a later one
128    /// in it (via `self.nodes`, not a snapshot). Returns the largest single-node displacement (`maxChange`).
129    pub fn pass(&mut self, reverse: bool) -> f64 {
130        let n = self.nodes.len();
131        let mut max_change = 0.0f64;
132        for k in 0..n {
133            let i = if reverse { n - 1 - k } else { k };
134            if self.nodes[i].pinned {
135                continue;
136            }
137            let start = self.offsets[i] as usize;
138            let end = self.offsets[i + 1] as usize;
139            if start == end {
140                continue;
141            }
142            let (mut x, mut y, mut total) = (0.0f64, 0.0f64, 0.0f64);
143            {
144                let node = &self.nodes[i];
145                for e in &self.edges[start..end] {
146                    if e.other < 0 {
147                        continue;
148                    }
149                    let other = &self.nodes[e.other as usize];
150                    let residual = js_hypot(other.x - node.x - e.dx, other.y - node.y - e.dy);
151                    let weight = e.weight * (1.0f64).min(6.0 / (1e-6f64).max(residual));
152                    x += (other.x - e.dx) * weight;
153                    y += (other.y - e.dy) * weight;
154                    total += weight;
155                }
156            }
157            if total == 0.0 {
158                continue;
159            }
160            let (px, py) = (x / total, y / total);
161            let node = &mut self.nodes[i];
162            let change = js_hypot(node.x - px, node.y - py);
163            if change > max_change {
164                max_change = change;
165            }
166            node.x = px;
167            node.y = py;
168        }
169        max_change
170    }
171
172    /// Largest edge residual over the current positions — same node/edge visitation order as `optimize()`'s
173    /// trailing residual loop (ascending node index, then edge order within each node).
174    pub fn residual(&self) -> f64 {
175        let mut residual = 0.0f64;
176        for i in 0..self.nodes.len() {
177            let start = self.offsets[i] as usize;
178            let end = self.offsets[i + 1] as usize;
179            let node = &self.nodes[i];
180            for e in &self.edges[start..end] {
181                if e.other < 0 {
182                    continue;
183                }
184                let other = &self.nodes[e.other as usize];
185                let r = js_hypot(other.x - node.x - e.dx, other.y - node.y - e.dy);
186                if r > residual {
187                    residual = r;
188                }
189            }
190        }
191        residual
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    fn two_node_chain(dx: f64, dy: f64) -> Graph {
200        // node 0 pinned at origin, node 1 free, one odometry edge 0->1 (dx,dy) and its mirror 1->0.
201        Graph {
202            nodes: vec![
203                Node {
204                    x: 0.0,
205                    y: 0.0,
206                    pinned: true,
207                },
208                Node {
209                    x: dx + 5.0,
210                    y: dy + 5.0,
211                    pinned: false,
212                },
213            ],
214            offsets: vec![0, 1, 2],
215            edges: vec![
216                Edge {
217                    other: 1,
218                    dx,
219                    dy,
220                    weight: 1.0,
221                },
222                Edge {
223                    other: 0,
224                    dx: -dx,
225                    dy: -dy,
226                    weight: 1.0,
227                },
228            ],
229        }
230    }
231
232    fn cycle(n: usize, closure: f64) -> Graph {
233        let nodes = (0..n)
234            .map(|i| Node {
235                x: i as f64 * 10.0,
236                y: 0.0,
237                pinned: i == 0,
238            })
239            .collect();
240        let mut offsets = vec![0];
241        let mut edges = Vec::new();
242        for i in 0..n {
243            edges.push(Edge {
244                other: ((i + 1) % n) as i32,
245                dx: if i + 1 == n {
246                    -(n as f64 - 1.0) * 10.0 + closure
247                } else {
248                    10.0
249                },
250                dy: 0.0,
251                weight: 1.0,
252            });
253            edges.push(Edge {
254                other: ((i + n - 1) % n) as i32,
255                dx: if i == 0 {
256                    (n as f64 - 1.0) * 10.0 - closure
257                } else {
258                    -10.0
259                },
260                dy: 0.0,
261                weight: 1.0,
262            });
263            offsets.push(edges.len() as u32);
264        }
265        Graph {
266            nodes,
267            offsets,
268            edges,
269        }
270    }
271
272    #[test]
273    fn cycle_solution_obeys_normal_equations() {
274        let mut graph = cycle(40, 2.0);
275        graph.edges[20].weight = 0.05;
276        graph.edges[23].weight = 0.05;
277        assert!(graph.solve_cycle());
278        for i in 1..graph.nodes.len() {
279            let mut gradient = (0.0, 0.0);
280            for e in &graph.edges[graph.offsets[i] as usize..graph.offsets[i + 1] as usize] {
281                gradient.0 +=
282                    e.weight * (graph.nodes[i].x + e.dx - graph.nodes[e.other as usize].x);
283                gradient.1 +=
284                    e.weight * (graph.nodes[i].y + e.dy - graph.nodes[e.other as usize].y);
285            }
286            assert!(js_hypot(gradient.0, gradient.1) < 1e-10);
287        }
288        assert_eq!(graph.nodes[0].x, 0.0);
289    }
290
291    #[test]
292    fn ineligible_cycles_are_left_unchanged() {
293        for invalid in 0..6 {
294            let mut graph = cycle(8, if invalid == 0 { 100.0 } else { 2.0 });
295            match invalid {
296                1 => graph.nodes[3].pinned = true,
297                2 => graph.edges[0].weight = 0.0,
298                3 => graph.edges[0].dx = f64::NAN,
299                4 => graph.edges[0].other = -1,
300                5 => graph.edges[0].weight = 2.0, // asymmetric mirror
301                _ => {}
302            }
303            assert!(!graph.solve_cycle());
304            for (i, node) in graph.nodes.iter().enumerate() {
305                assert_eq!(node.x, i as f64 * 10.0);
306            }
307        }
308    }
309
310    #[test]
311    fn relaxes_a_free_node_toward_its_pinned_neighbour() {
312        let mut g = two_node_chain(10.0, -4.0);
313        let change = g.pass(false);
314        assert!(change > 0.0);
315        assert!((g.nodes[1].x - 10.0).abs() < 1e-9);
316        assert!((g.nodes[1].y - -4.0).abs() < 1e-9);
317        assert_eq!(g.residual(), 0.0);
318    }
319
320    #[test]
321    fn missing_other_is_skipped_not_fatal() {
322        let mut g = Graph {
323            nodes: vec![Node {
324                x: 1.0,
325                y: 1.0,
326                pinned: false,
327            }],
328            offsets: vec![0, 1],
329            edges: vec![Edge {
330                other: -1,
331                dx: 0.0,
332                dy: 0.0,
333                weight: 1.0,
334            }],
335        };
336        let change = g.pass(false);
337        assert_eq!(change, 0.0);
338        assert_eq!(g.nodes[0].x, 1.0);
339    }
340}