use apex_manifolds::LieGroup;
use apex_manifolds::se3::SE3;
use faer::prelude::ReborrowMut;
use nalgebra::{Matrix3, Vector3};
use crate::core::variable::ManifoldVariable;
use crate::factors::Factor;
use crate::factors::lidar::distance_field::{DistanceField, IcpFactor};
#[derive(Clone, Debug)]
pub struct Plane {
pub normal: Vector3<f64>,
pub offset: f64,
}
impl Plane {
pub fn new(normal: Vector3<f64>, offset: f64) -> Result<Self, String> {
let norm = normal.norm();
if !(norm.is_finite() && norm > 1e-12) {
return Err("plane normal must be non-zero and finite".into());
}
Ok(Self {
normal: normal / norm,
offset: offset / norm,
})
}
}
#[derive(Clone)]
pub struct PointToPlaneFactor {
pub plane: Plane,
}
impl PointToPlaneFactor {
pub fn new(plane: Plane) -> Self {
Self { plane }
}
}
impl Factor for PointToPlaneFactor {
fn linearize(
&self,
params: &[&[f64]],
residual: &mut [f64],
jacobian: Option<faer::mat::MatMut<'_, f64>>,
) {
debug_assert_eq!(params.len(), 2, "PointToPlaneFactor expects [T_wr, p_body]");
debug_assert_eq!(params[0].len(), 7, "params[0] must be SE3 (7D)");
debug_assert_eq!(params[1].len(), 3, "params[1] must be a 3D point");
let pose = SE3::from_param_slice(params[0]);
let p_body = Vector3::new(params[1][0], params[1][1], params[1][2]);
let mut j_pose = SE3::zero_jacobian();
let mut j_point = Matrix3::zeros();
let predicted = pose.act(&p_body, Some(&mut j_pose), Some(&mut j_point));
residual[0] = self.plane.normal.dot(&predicted) + self.plane.offset;
let Some(mut jac) = jacobian else { return };
let n_t = self.plane.normal.transpose();
let d_pose = n_t * j_pose.fixed_view::<3, 6>(0, 0); let d_point = n_t * j_point; for col in 0..6 {
*jac.rb_mut().get_mut(0, col) = d_pose[(0, col)];
}
for col in 0..3 {
*jac.rb_mut().get_mut(0, 6 + col) = d_point[(0, col)];
}
}
fn residual_dim(&self) -> usize {
1
}
fn jacobian_shape(&self) -> (usize, usize) {
(1, 9)
}
fn validate_variables(&self, variables: &[&dyn ManifoldVariable]) -> Result<(), String> {
if variables.len() != 2
|| variables[0].as_param_slice().len() != SE3::REP_SIZE
|| variables[1].as_param_slice().len() != 3
{
return Err("PointToPlaneFactor expects [SE3 pose, 3D point]".into());
}
Ok(())
}
}
pub struct PrecomputedPlane {
point: Vector3<f64>,
normal: Vector3<f64>,
}
impl PrecomputedPlane {
pub fn new(point: Vector3<f64>, normal: Vector3<f64>) -> Self {
Self {
point,
normal: normal.normalize(),
}
}
}
impl DistanceField for PrecomputedPlane {
fn query(&self, point: &Vector3<f64>) -> Option<(f64, Vector3<f64>)> {
let val = self.normal.dot(&(point - self.point));
Some((val, self.normal))
}
}
pub type LidarPlaneFactor = IcpFactor<PrecomputedPlane>;
pub fn lidar_plane_factor_isotropic(
point_b: Vector3<f64>,
plane_point: Vector3<f64>,
plane_normal: Vector3<f64>,
sigma: f64,
) -> LidarPlaneFactor {
IcpFactor::new(
PrecomputedPlane::new(plane_point, plane_normal),
point_b,
sigma,
)
}
#[cfg(test)]
mod tests {
use super::*;
use apex_manifolds::Tangent;
use apex_manifolds::se3::SE3Tangent;
type TestResult<T> = Result<T, Box<dyn std::error::Error>>;
#[test]
fn zero_residual_on_plane() -> TestResult<()> {
let plane = Plane::new(Vector3::new(0.0, 0.0, 1.0), -1.0)?;
let p_body = Vector3::new(0.5, -0.4, 0.7);
let rotation_q = nalgebra::UnitQuaternion::from_euler_angles(0.02, -0.01, 0.03);
let r = rotation_q.to_rotation_matrix().into_inner();
let p_world = Vector3::new(0.4, -0.6, 1.0);
let translation = p_world - r * p_body;
let pose = SE3::from_isometry(nalgebra::Isometry3::from_parts(
nalgebra::Translation3::from(translation),
rotation_q,
));
let factor = PointToPlaneFactor::new(plane);
let mut residual = vec![0.0; 1];
factor.linearize(
&[pose.as_param_slice(), &[p_body.x, p_body.y, p_body.z]],
&mut residual,
None,
);
assert!(residual[0].abs() < 1e-12, "residual = {}", residual[0]);
Ok(())
}
#[test]
fn finite_difference_jacobians() -> TestResult<()> {
let plane = Plane::new(Vector3::new(0.3, -0.5, 0.8), 0.2)?;
let pose = SE3::from_isometry(nalgebra::Isometry3::from_parts(
nalgebra::Translation3::new(0.4, -0.2, 0.9),
nalgebra::UnitQuaternion::from_euler_angles(0.03, 0.05, -0.02),
));
let p_body = Vector3::new(0.2, 0.6, -0.3);
let factor = PointToPlaneFactor::new(plane);
let pose_vec: Vec<f64> = pose.as_param_slice().to_vec();
let body_vec = [p_body.x, p_body.y, p_body.z];
let mut r0 = vec![0.0; 1];
let mut jac_buf = vec![0.0; 9];
let jac_mut = faer::mat::MatMut::from_column_major_slice_mut(&mut jac_buf, 1, 9);
factor.linearize(&[&pose_vec, &body_vec], &mut r0, Some(jac_mut));
const EPS: f64 = 1e-6;
const TOL: f64 = 1e-4;
for col in 0..6 {
let mut tan = [0.0f64; 6];
tan[col] = EPS;
let perturbed: Vec<f64> = pose
.right_plus(&SE3Tangent::from_slice(&tan), None, None)
.as_param_slice()
.to_vec();
let mut r_pert = vec![0.0; 1];
factor.linearize(&[&perturbed, &body_vec], &mut r_pert, None);
let fd = (r_pert[0] - r0[0]) / EPS;
let ana = jac_buf[col];
assert!(
(fd - ana).abs() < TOL,
"pose[{col}]: analytical={ana:.6} fd={fd:.6}"
);
}
for col in 0..3 {
let mut plus = body_vec;
let mut minus = body_vec;
plus[col] += EPS;
minus[col] -= EPS;
let mut r_plus = vec![0.0; 1];
let mut r_minus = vec![0.0; 1];
factor.linearize(&[&pose_vec, &plus], &mut r_plus, None);
factor.linearize(&[&pose_vec, &minus], &mut r_minus, None);
let fd = (r_plus[0] - r_minus[0]) / (2.0 * EPS);
let ana = jac_buf[6 + col];
assert!(
(fd - ana).abs() < TOL,
"point[{col}]: analytical={ana:.6} fd={fd:.6}"
);
}
Ok(())
}
#[test]
fn rejects_degenerate_normal() {
assert!(Plane::new(Vector3::zeros(), 0.0).is_err());
assert!(Plane::new(Vector3::new(f64::NAN, 0.0, 1.0), 0.0).is_err());
}
#[test]
fn normal_is_normalized_on_construction() -> TestResult<()> {
let plane = Plane::new(Vector3::new(0.0, 0.0, 2.0), -4.0)?;
assert!((plane.normal.norm() - 1.0).abs() < 1e-12);
assert!((plane.offset - (-2.0)).abs() < 1e-12);
Ok(())
}
use crate::factors::common::test_utils::{
compute_residual, compute_with_jacobian, identity_pose, make_pose, perturb_se3,
};
use nalgebra::UnitQuaternion;
#[test]
fn plane_zero_residual_on_plane() {
let t_wa = identity_pose();
let t_wb = identity_pose();
let point_b = Vector3::new(1.0, 2.0, 5.0);
let factor =
lidar_plane_factor_isotropic(point_b, Vector3::new(0.0, 0.0, 5.0), Vector3::z(), 1.0);
let r = compute_residual(&factor, t_wa.as_slice(), t_wb.as_slice());
assert!(r[0].abs() < 1e-10, "residual = {} should be zero", r[0]);
}
#[test]
fn plane_residual_matches_closed_form() {
let t_wa = identity_pose();
let t_wb = identity_pose();
let point_b = Vector3::new(1.0, 2.0, 7.0);
let factor =
lidar_plane_factor_isotropic(point_b, Vector3::new(0.0, 0.0, 5.0), Vector3::z(), 1.0);
let r = compute_residual(&factor, t_wa.as_slice(), t_wb.as_slice());
assert!((r[0] - 2.0).abs() < 1e-10, "residual = {}", r[0]);
}
#[test]
fn plane_finite_difference_jacobians() {
let q_a = UnitQuaternion::from_axis_angle(
&nalgebra::Unit::new_normalize(Vector3::new(0.0, 1.0, 0.0)),
0.2,
);
let q_b = UnitQuaternion::from_axis_angle(
&nalgebra::Unit::new_normalize(Vector3::new(0.0, 0.0, 1.0)),
-0.15,
);
let t_wa = make_pose(1.0, -0.5, 0.3, q_a);
let t_wb = make_pose(2.0, 1.0, -0.2, q_b);
let point_b = Vector3::new(0.5, -0.3, 4.0);
let normal = Vector3::new(0.0, 0.3, 0.9).normalize();
let factor =
lidar_plane_factor_isotropic(point_b, Vector3::new(0.0, 0.0, 3.0), normal, 0.5);
let (r0, jac) = compute_with_jacobian(&factor, t_wa.as_slice(), t_wb.as_slice());
const EPS: f64 = 1e-7;
const TOL: f64 = 1e-4;
for col in 0..6 {
let mut tan = [0.0f64; 6];
tan[col] = EPS;
let t_wa_p = perturb_se3(t_wa.as_slice(), &tan);
let r_pert = compute_residual(&factor, t_wa_p.as_slice(), t_wb.as_slice());
let fd = (r_pert[0] - r0[0]) / EPS;
crate::factors::common::test_utils::assert_close(
jac[(0, col)],
fd,
TOL,
&format!("J_T_WA[0,{col}]"),
);
}
for col in 0..6 {
let mut tan = [0.0f64; 6];
tan[col] = EPS;
let t_wb_p = perturb_se3(t_wb.as_slice(), &tan);
let r_pert = compute_residual(&factor, t_wa.as_slice(), t_wb_p.as_slice());
let fd = (r_pert[0] - r0[0]) / EPS;
crate::factors::common::test_utils::assert_close(
jac[(0, 6 + col)],
fd,
TOL,
&format!("J_T_WB[0,{col}]"),
);
}
}
#[test]
fn plane_dimension_is_one() {
let factor =
lidar_plane_factor_isotropic(Vector3::zeros(), Vector3::zeros(), Vector3::z(), 1.0);
assert_eq!(factor.residual_dim(), 1);
assert_eq!(factor.jacobian_shape(), (1, 12));
}
}