Skip to main content

oxiphysics_geometry/
robot_geometry.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Robot geometry: DH parameters, forward/inverse kinematics, Jacobians,
5//! workspace computation, collision checking, and self-collision detection.
6//!
7//! All transforms are represented as 4×4 homogeneous matrices stored in
8//! row-major order as `[f64; 16]`. Joint arrays use plain `[f64; 3]` vectors
9//! for positions and axes. No external linear-algebra crate is used.
10
11#![allow(dead_code)]
12#![allow(clippy::too_many_arguments)]
13
14use rand::RngExt;
15use std::f64::consts::PI;
16
17// ---------------------------------------------------------------------------
18// Low-level math helpers
19// ---------------------------------------------------------------------------
20
21/// Dot product of two 3-vectors.
22#[inline]
23fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
24    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
25}
26
27/// Vector subtraction.
28#[inline]
29fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
30    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
31}
32
33/// Vector addition.
34#[inline]
35fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
36    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
37}
38
39/// Scalar multiplication of a 3-vector.
40#[inline]
41fn scale3(a: [f64; 3], s: f64) -> [f64; 3] {
42    [a[0] * s, a[1] * s, a[2] * s]
43}
44
45/// Euclidean length of a 3-vector.
46#[inline]
47fn len3(a: [f64; 3]) -> f64 {
48    dot3(a, a).sqrt()
49}
50
51/// Normalize a 3-vector; returns zero vector if near-zero length.
52#[inline]
53fn normalize3(a: [f64; 3]) -> [f64; 3] {
54    let l = len3(a);
55    if l < 1e-14 {
56        [0.0; 3]
57    } else {
58        scale3(a, 1.0 / l)
59    }
60}
61
62/// Cross product.
63#[inline]
64fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
65    [
66        a[1] * b[2] - a[2] * b[1],
67        a[2] * b[0] - a[0] * b[2],
68        a[0] * b[1] - a[1] * b[0],
69    ]
70}
71
72// ---------------------------------------------------------------------------
73// 4×4 homogeneous matrix (row-major)
74// ---------------------------------------------------------------------------
75
76/// A 4×4 homogeneous transformation matrix stored row-major.
77///
78/// Index layout: `m[row * 4 + col]`.
79pub type Mat4 = [f64; 16];
80
81/// Returns the 4×4 identity matrix.
82#[allow(dead_code)]
83pub fn mat4_identity() -> Mat4 {
84    let mut m = [0.0f64; 16];
85    m[0] = 1.0;
86    m[5] = 1.0;
87    m[10] = 1.0;
88    m[15] = 1.0;
89    m
90}
91
92/// Multiplies two 4×4 row-major matrices.
93pub fn mat4_mul(a: &Mat4, b: &Mat4) -> Mat4 {
94    let mut c = [0.0f64; 16];
95    for row in 0..4 {
96        for col in 0..4 {
97            let mut s = 0.0f64;
98            for k in 0..4 {
99                s += a[row * 4 + k] * b[k * 4 + col];
100            }
101            c[row * 4 + col] = s;
102        }
103    }
104    c
105}
106
107/// Transforms a 3-vector by a 4×4 homogeneous matrix (w = 1).
108pub fn mat4_transform_point(m: &Mat4, p: [f64; 3]) -> [f64; 3] {
109    [
110        m[0] * p[0] + m[1] * p[1] + m[2] * p[2] + m[3],
111        m[4] * p[0] + m[5] * p[1] + m[6] * p[2] + m[7],
112        m[8] * p[0] + m[9] * p[1] + m[10] * p[2] + m[11],
113    ]
114}
115
116/// Transforms a 3-direction (w = 0) by a 4×4 matrix.
117pub fn mat4_transform_dir(m: &Mat4, d: [f64; 3]) -> [f64; 3] {
118    [
119        m[0] * d[0] + m[1] * d[1] + m[2] * d[2],
120        m[4] * d[0] + m[5] * d[1] + m[6] * d[2],
121        m[8] * d[0] + m[9] * d[1] + m[10] * d[2],
122    ]
123}
124
125/// Extracts the translation component from a 4×4 matrix.
126pub fn mat4_translation(m: &Mat4) -> [f64; 3] {
127    [m[3], m[7], m[11]]
128}
129
130/// Builds a rotation matrix about the X axis.
131fn rot_x(angle: f64) -> Mat4 {
132    let c = angle.cos();
133    let s = angle.sin();
134    let mut m = mat4_identity();
135    m[5] = c;
136    m[6] = -s;
137    m[9] = s;
138    m[10] = c;
139    m
140}
141
142/// Builds a rotation matrix about the Z axis.
143fn rot_z(angle: f64) -> Mat4 {
144    let c = angle.cos();
145    let s = angle.sin();
146    let mut m = mat4_identity();
147    m[0] = c;
148    m[1] = -s;
149    m[4] = s;
150    m[5] = c;
151    m
152}
153
154/// Builds a pure translation matrix.
155fn trans(tx: f64, ty: f64, tz: f64) -> Mat4 {
156    let mut m = mat4_identity();
157    m[3] = tx;
158    m[7] = ty;
159    m[11] = tz;
160    m
161}
162
163// ---------------------------------------------------------------------------
164// Denavit-Hartenberg (DH) parameters
165// ---------------------------------------------------------------------------
166
167/// Standard Denavit-Hartenberg parameters for a single joint/link.
168///
169/// Convention: `T = Rz(θ) · Tz(d) · Tx(a) · Rx(α)`.
170#[derive(Debug, Clone, Copy)]
171pub struct DhParam {
172    /// Joint angle θ (radians) — variable for revolute joints.
173    pub theta: f64,
174    /// Link offset d (metres) — variable for prismatic joints.
175    pub d: f64,
176    /// Link length a (metres).
177    pub a: f64,
178    /// Link twist α (radians).
179    pub alpha: f64,
180}
181
182impl DhParam {
183    /// Creates a new DH parameter set.
184    pub fn new(theta: f64, d: f64, a: f64, alpha: f64) -> Self {
185        Self { theta, d, a, alpha }
186    }
187
188    /// Returns the 4×4 homogeneous transform for this DH frame.
189    pub fn transform(&self) -> Mat4 {
190        let rz = rot_z(self.theta);
191        let tz = trans(0.0, 0.0, self.d);
192        let tx = trans(self.a, 0.0, 0.0);
193        let rx = rot_x(self.alpha);
194        mat4_mul(&mat4_mul(&mat4_mul(&rz, &tz), &tx), &rx)
195    }
196}
197
198// ---------------------------------------------------------------------------
199// Joint types
200// ---------------------------------------------------------------------------
201
202/// Type of a robot joint.
203#[derive(Debug, Clone, Copy, PartialEq)]
204pub enum JointType {
205    /// Revolute joint — rotates about Z axis.
206    Revolute,
207    /// Prismatic joint — translates along Z axis.
208    Prismatic,
209    /// Fixed joint — no degree of freedom.
210    Fixed,
211}
212
213// ---------------------------------------------------------------------------
214// Joint limits
215// ---------------------------------------------------------------------------
216
217/// Angular or linear limits for a single joint.
218#[derive(Debug, Clone, Copy)]
219pub struct JointLimits {
220    /// Minimum joint value (radians or metres).
221    pub min: f64,
222    /// Maximum joint value (radians or metres).
223    pub max: f64,
224    /// Maximum joint velocity (rad/s or m/s).
225    pub max_velocity: f64,
226    /// Maximum joint torque/force (Nm or N).
227    pub max_effort: f64,
228}
229
230impl JointLimits {
231    /// Creates joint limits.
232    pub fn new(min: f64, max: f64, max_velocity: f64, max_effort: f64) -> Self {
233        Self {
234            min,
235            max,
236            max_velocity,
237            max_effort,
238        }
239    }
240
241    /// Returns true if `value` is within \[min, max\].
242    pub fn is_within(&self, value: f64) -> bool {
243        value >= self.min && value <= self.max
244    }
245
246    /// Clamps `value` to \[min, max\].
247    pub fn clamp(&self, value: f64) -> f64 {
248        value.clamp(self.min, self.max)
249    }
250}
251
252// ---------------------------------------------------------------------------
253// RobotLink
254// ---------------------------------------------------------------------------
255
256/// Describes a single link of a robot arm.
257#[derive(Debug, Clone)]
258pub struct RobotLink {
259    /// Human-readable name of the link.
260    pub name: String,
261    /// DH parameters relative to the parent frame.
262    pub dh: DhParam,
263    /// Type of the joint connecting to this link.
264    pub joint_type: JointType,
265    /// Joint limits (optional for fixed joints).
266    pub limits: Option<JointLimits>,
267    /// Capsule radius used for collision checking (metres).
268    pub collision_radius: f64,
269    /// Capsule half-length used for collision checking (metres).
270    pub collision_half_len: f64,
271}
272
273impl RobotLink {
274    /// Creates a new robot link.
275    pub fn new(
276        name: impl Into<String>,
277        dh: DhParam,
278        joint_type: JointType,
279        limits: Option<JointLimits>,
280        collision_radius: f64,
281        collision_half_len: f64,
282    ) -> Self {
283        Self {
284            name: name.into(),
285            dh,
286            joint_type,
287            limits,
288            collision_radius,
289            collision_half_len,
290        }
291    }
292}
293
294// ---------------------------------------------------------------------------
295// RobotArm — the main robot structure
296// ---------------------------------------------------------------------------
297
298/// A serial-chain robot arm described by a sequence of DH links.
299///
300/// Supports forward kinematics, Jacobian computation (analytical and numerical),
301/// Newton-Raphson inverse kinematics, workspace Monte-Carlo sampling,
302/// joint-limit checking, and self-collision detection.
303#[derive(Debug, Clone)]
304pub struct RobotArm {
305    /// Name of the robot.
306    pub name: String,
307    /// Ordered list of links (joint 0 is closest to base).
308    pub links: Vec<RobotLink>,
309    /// Base transform applied before all joint transforms.
310    pub base_transform: Mat4,
311}
312
313impl RobotArm {
314    /// Creates a new robot arm.
315    pub fn new(name: impl Into<String>, links: Vec<RobotLink>) -> Self {
316        Self {
317            name: name.into(),
318            links,
319            base_transform: mat4_identity(),
320        }
321    }
322
323    /// Sets the base transform and returns `self` for chaining.
324    pub fn with_base_transform(mut self, t: Mat4) -> Self {
325        self.base_transform = t;
326        self
327    }
328
329    /// Number of degrees of freedom (non-Fixed joints).
330    pub fn dof(&self) -> usize {
331        self.links
332            .iter()
333            .filter(|l| l.joint_type != JointType::Fixed)
334            .count()
335    }
336
337    /// Returns the DH parameter for link `i` with the joint variable (`q`)
338    /// applied depending on joint type.
339    fn dh_with_q(&self, link_idx: usize, q: f64) -> DhParam {
340        let link = &self.links[link_idx];
341        let mut dh = link.dh;
342        match link.joint_type {
343            JointType::Revolute => dh.theta += q,
344            JointType::Prismatic => dh.d += q,
345            JointType::Fixed => {}
346        }
347        dh
348    }
349
350    /// Computes all intermediate frames (base → end-effector) for a given joint
351    /// configuration `q`. Returns one matrix per link plus the base.
352    pub fn forward_frames(&self, q: &[f64]) -> Vec<Mat4> {
353        let mut frames = Vec::with_capacity(self.links.len() + 1);
354        frames.push(self.base_transform);
355        let mut q_iter = q.iter().copied();
356        for (i, link) in self.links.iter().enumerate() {
357            let qi = if link.joint_type == JointType::Fixed {
358                0.0
359            } else {
360                q_iter.next().unwrap_or(0.0)
361            };
362            let dh = self.dh_with_q(i, qi);
363            let t = mat4_mul(
364                frames.last().expect("collection should not be empty"),
365                &dh.transform(),
366            );
367            frames.push(t);
368        }
369        frames
370    }
371
372    /// Returns the end-effector transform for joint configuration `q`.
373    pub fn end_effector(&self, q: &[f64]) -> Mat4 {
374        let frames = self.forward_frames(q);
375        *frames.last().expect("collection should not be empty")
376    }
377
378    /// Returns the end-effector position `[x, y, z]` for joint configuration `q`.
379    pub fn end_effector_pos(&self, q: &[f64]) -> [f64; 3] {
380        mat4_translation(&self.end_effector(q))
381    }
382
383    // -----------------------------------------------------------------------
384    // Jacobian (numerical)
385    // -----------------------------------------------------------------------
386
387    /// Computes the 3×n positional Jacobian numerically (finite differences).
388    ///
389    /// Returns a flat row-major array of shape `3 × dof`.
390    pub fn jacobian_numerical(&self, q: &[f64]) -> Vec<f64> {
391        let n = q.len();
392        let eps = 1e-7;
393        let p0 = self.end_effector_pos(q);
394        let mut j = vec![0.0f64; 3 * n];
395        let mut qp = q.to_vec();
396        for i in 0..n {
397            qp[i] += eps;
398            let p1 = self.end_effector_pos(&qp);
399            qp[i] = q[i];
400            for row in 0..3 {
401                j[row * n + i] = (p1[row] - p0[row]) / eps;
402            }
403        }
404        j
405    }
406
407    /// Computes the full 6×n Jacobian (position + orientation) numerically.
408    ///
409    /// Orientation rows use angle-axis approximation via the rotation part of
410    /// the end-effector transform. Returns flat row-major `6 × dof`.
411    pub fn jacobian_6dof_numerical(&self, q: &[f64]) -> Vec<f64> {
412        let n = q.len();
413        let eps = 1e-7;
414        let t0 = self.end_effector(q);
415        let mut j = vec![0.0f64; 6 * n];
416        let mut qp = q.to_vec();
417        for i in 0..n {
418            qp[i] += eps;
419            let t1 = self.end_effector(&qp);
420            qp[i] = q[i];
421            // Position columns
422            for row in 0..3 {
423                j[row * n + i] = (t1[row * 4 + 3] - t0[row * 4 + 3]) / eps;
424            }
425            // Orientation columns (skew-symmetric R_delta)
426            let r_delta = mat4_mul(&mat4_transpose(&t0), &t1);
427            // vee map: extract angular velocity from skew part
428            let wx = (r_delta[6] - r_delta[9]) / (2.0 * eps);
429            let wy = (r_delta[8] - r_delta[2]) / (2.0 * eps);
430            let wz = (r_delta[1] - r_delta[4]) / (2.0 * eps);
431            j[3 * n + i] = wx;
432            j[4 * n + i] = wy;
433            j[5 * n + i] = wz;
434        }
435        j
436    }
437
438    /// Computes the 3×n analytical Jacobian using the geometric (cross-product) method.
439    ///
440    /// For revolute joints: `J_i = z_{i-1} × (p_e − p_{i-1})`.
441    /// For prismatic joints: `J_i = z_{i-1}`.
442    pub fn jacobian_analytical(&self, q: &[f64]) -> Vec<f64> {
443        let frames = self.forward_frames(q);
444        let n = q.len();
445        let p_e = mat4_translation(frames.last().expect("collection should not be empty"));
446        let mut j = vec![0.0f64; 3 * n];
447        let mut qi = 0usize;
448        for (i, link) in self.links.iter().enumerate() {
449            if link.joint_type == JointType::Fixed {
450                continue;
451            }
452            let frame = &frames[i]; // frame before this joint
453            // Z axis of this frame (third column of rotation)
454            let z = [frame[2], frame[6], frame[10]];
455            let p_i = mat4_translation(frame);
456            let col: [f64; 3] = match link.joint_type {
457                JointType::Revolute => cross3(z, sub3(p_e, p_i)),
458                JointType::Prismatic => z,
459                JointType::Fixed => unreachable!(),
460            };
461            for row in 0..3 {
462                j[row * n + qi] = col[row];
463            }
464            qi += 1;
465        }
466        j
467    }
468
469    // -----------------------------------------------------------------------
470    // Jacobian pseudo-inverse (Moore-Penrose via SVD-free damped LS)
471    // -----------------------------------------------------------------------
472
473    /// Computes the damped least-squares pseudo-inverse of a `3 × n` Jacobian.
474    ///
475    /// Uses the formula `J^+ = J^T (J J^T + λ²I)^{-1}` with closed-form
476    /// inversion for the 3×3 system.
477    pub fn jacobian_pseudoinverse(j: &[f64], n: usize, lambda: f64) -> Vec<f64> {
478        // J is 3×n.  Compute A = J J^T (3×3)
479        let mut a = [0.0f64; 9];
480        for r in 0..3 {
481            for c in 0..3 {
482                let mut s = 0.0f64;
483                for k in 0..n {
484                    s += j[r * n + k] * j[c * n + k];
485                }
486                a[r * 3 + c] = s;
487            }
488        }
489        // Add damping
490        let lam2 = lambda * lambda;
491        a[0] += lam2;
492        a[4] += lam2;
493        a[8] += lam2;
494        // Invert 3×3
495        let inv = mat3_inverse(&a);
496        // J^+ = J^T * inv  (n×3)
497        let mut jp = vec![0.0f64; n * 3];
498        for r in 0..n {
499            for c in 0..3 {
500                let mut s = 0.0f64;
501                for k in 0..3 {
502                    s += j[k * n + r] * inv[k * 3 + c];
503                }
504                jp[r * 3 + c] = s;
505            }
506        }
507        jp
508    }
509
510    // -----------------------------------------------------------------------
511    // Inverse kinematics (Newton-Raphson)
512    // -----------------------------------------------------------------------
513
514    /// Attempts to solve inverse kinematics using Newton-Raphson iteration.
515    ///
516    /// Returns `Ok(q)` if converged within `max_iter` steps, or `Err` with the
517    /// best solution found.
518    pub fn ik_newton_raphson(
519        &self,
520        target: [f64; 3],
521        q_init: &[f64],
522        max_iter: usize,
523        tol: f64,
524        lambda: f64,
525    ) -> Result<Vec<f64>, Vec<f64>> {
526        let mut q = q_init.to_vec();
527        let n = q.len();
528        for _iter in 0..max_iter {
529            let p = self.end_effector_pos(&q);
530            let e = sub3(target, p);
531            if len3(e) < tol {
532                return Ok(q);
533            }
534            let j = self.jacobian_analytical(&q);
535            let jp = Self::jacobian_pseudoinverse(&j, n, lambda);
536            // dq = J^+ * e
537            for i in 0..n {
538                let dqi = jp[i * 3] * e[0] + jp[i * 3 + 1] * e[1] + jp[i * 3 + 2] * e[2];
539                q[i] += dqi;
540            }
541            // Enforce joint limits
542            for (i, link) in self.links.iter().enumerate() {
543                if link.joint_type == JointType::Fixed {
544                    continue;
545                }
546                let qi_idx = self.joint_index(i);
547                if let (Some(qi_idx), Some(lim)) = (qi_idx, &link.limits)
548                    && qi_idx < q.len()
549                {
550                    q[qi_idx] = lim.clamp(q[qi_idx]);
551                }
552            }
553        }
554        Err(q)
555    }
556
557    /// Returns the q-vector index for link `link_idx`, or `None` if fixed.
558    fn joint_index(&self, link_idx: usize) -> Option<usize> {
559        let mut idx = 0usize;
560        for (i, l) in self.links.iter().enumerate() {
561            if l.joint_type == JointType::Fixed {
562                continue;
563            }
564            if i == link_idx {
565                return Some(idx);
566            }
567            idx += 1;
568        }
569        None
570    }
571
572    // -----------------------------------------------------------------------
573    // Workspace Monte-Carlo
574    // -----------------------------------------------------------------------
575
576    /// Samples `n_samples` random joint configurations and returns the set of
577    /// reachable end-effector positions.
578    pub fn workspace_monte_carlo(&self, n_samples: usize) -> Vec<[f64; 3]> {
579        let mut rng = rand::rng();
580        let dof = self.dof();
581        let limits: Vec<JointLimits> = self
582            .links
583            .iter()
584            .filter(|l| l.joint_type != JointType::Fixed)
585            .map(|l| l.limits.unwrap_or(JointLimits::new(-PI, PI, 10.0, 100.0)))
586            .collect();
587        let mut points = Vec::with_capacity(n_samples);
588        for _ in 0..n_samples {
589            let q: Vec<f64> = (0..dof)
590                .map(|i| rng.random_range(limits[i].min..limits[i].max))
591                .collect();
592            points.push(self.end_effector_pos(&q));
593        }
594        points
595    }
596
597    /// Estimates the reachable workspace bounding box from a Monte-Carlo sample.
598    ///
599    /// Returns `(min_corner, max_corner)` or `None` if no samples.
600    pub fn workspace_bounding_box(&self, n_samples: usize) -> Option<([f64; 3], [f64; 3])> {
601        let pts = self.workspace_monte_carlo(n_samples);
602        if pts.is_empty() {
603            return None;
604        }
605        let mut mn = pts[0];
606        let mut mx = pts[0];
607        for p in &pts[1..] {
608            for k in 0..3 {
609                if p[k] < mn[k] {
610                    mn[k] = p[k];
611                }
612                if p[k] > mx[k] {
613                    mx[k] = p[k];
614                }
615            }
616        }
617        Some((mn, mx))
618    }
619
620    // -----------------------------------------------------------------------
621    // Collision helpers
622    // -----------------------------------------------------------------------
623
624    /// Returns all link capsule endpoints in world space for configuration `q`.
625    ///
626    /// Each entry is `(p_start, p_end, radius)` describing the capsule axis.
627    pub fn link_capsules(&self, q: &[f64]) -> Vec<([f64; 3], [f64; 3], f64)> {
628        let frames = self.forward_frames(q);
629        let mut caps = Vec::with_capacity(self.links.len());
630        for (i, link) in self.links.iter().enumerate() {
631            let frame = &frames[i + 1];
632            let center = mat4_translation(frame);
633            // Local capsule axis is along local Z
634            let z_world = [frame[2], frame[6], frame[10]];
635            let hl = link.collision_half_len;
636            let p_start = sub3(center, scale3(z_world, hl));
637            let p_end = add3(center, scale3(z_world, hl));
638            caps.push((p_start, p_end, link.collision_radius));
639        }
640        caps
641    }
642
643    /// Checks whether any robot link capsule intersects an obstacle sphere.
644    ///
645    /// Returns `true` if any link is in collision.
646    pub fn check_collision_sphere(
647        &self,
648        q: &[f64],
649        sphere_center: [f64; 3],
650        sphere_radius: f64,
651    ) -> bool {
652        for (a, b, r) in self.link_capsules(q) {
653            let dist = dist_point_segment(sphere_center, a, b);
654            if dist < r + sphere_radius {
655                return true;
656            }
657        }
658        false
659    }
660
661    /// Checks whether any robot link capsule intersects an axis-aligned box.
662    ///
663    /// Returns `true` if any link is in collision.
664    pub fn check_collision_aabb(&self, q: &[f64], box_min: [f64; 3], box_max: [f64; 3]) -> bool {
665        for (a, b, r) in self.link_capsules(q) {
666            if capsule_aabb_overlap(a, b, r, box_min, box_max) {
667                return true;
668            }
669        }
670        false
671    }
672
673    // -----------------------------------------------------------------------
674    // Self-collision detection
675    // -----------------------------------------------------------------------
676
677    /// Detects self-collision between non-adjacent link capsules.
678    ///
679    /// Links with indices that differ by 1 (adjacent) are always skipped.
680    /// Returns `true` if any non-adjacent pair of capsules overlaps.
681    pub fn check_self_collision(&self, q: &[f64]) -> bool {
682        let caps = self.link_capsules(q);
683        let n = caps.len();
684        for i in 0..n {
685            for j in (i + 2)..n {
686                let (a1, b1, r1) = caps[i];
687                let (a2, b2, r2) = caps[j];
688                let dist = dist_segment_segment(a1, b1, a2, b2);
689                if dist < r1 + r2 {
690                    return true;
691                }
692            }
693        }
694        false
695    }
696
697    // -----------------------------------------------------------------------
698    // Joint limit checking
699    // -----------------------------------------------------------------------
700
701    /// Returns `true` if all joint values in `q` satisfy the robot's joint limits.
702    pub fn joints_within_limits(&self, q: &[f64]) -> bool {
703        let mut qi = 0usize;
704        for link in &self.links {
705            if link.joint_type == JointType::Fixed {
706                continue;
707            }
708            if qi >= q.len() {
709                break;
710            }
711            if let Some(lim) = &link.limits
712                && !lim.is_within(q[qi])
713            {
714                return false;
715            }
716            qi += 1;
717        }
718        true
719    }
720
721    /// Clamps all joints in `q` to the robot's limits and returns the result.
722    pub fn clamp_joints(&self, q: &[f64]) -> Vec<f64> {
723        let mut out = q.to_vec();
724        let mut qi = 0usize;
725        for link in &self.links {
726            if link.joint_type == JointType::Fixed {
727                continue;
728            }
729            if qi >= out.len() {
730                break;
731            }
732            if let Some(lim) = &link.limits {
733                out[qi] = lim.clamp(out[qi]);
734            }
735            qi += 1;
736        }
737        out
738    }
739
740    // -----------------------------------------------------------------------
741    // Swept volume
742    // -----------------------------------------------------------------------
743
744    /// Computes a coarse AABB for the swept volume of the end-effector as joints
745    /// vary linearly from `q_start` to `q_end` using `n_steps` samples.
746    pub fn swept_volume_aabb(
747        &self,
748        q_start: &[f64],
749        q_end: &[f64],
750        n_steps: usize,
751    ) -> ([f64; 3], [f64; 3]) {
752        let n_steps = n_steps.max(2);
753        let mut mn = [f64::INFINITY; 3];
754        let mut mx = [f64::NEG_INFINITY; 3];
755        for step in 0..=n_steps {
756            let t = step as f64 / n_steps as f64;
757            let q: Vec<f64> = q_start
758                .iter()
759                .zip(q_end.iter())
760                .map(|(a, b)| a + t * (b - a))
761                .collect();
762            let p = self.end_effector_pos(&q);
763            for k in 0..3 {
764                if p[k] < mn[k] {
765                    mn[k] = p[k];
766                }
767                if p[k] > mx[k] {
768                    mx[k] = p[k];
769                }
770            }
771        }
772        (mn, mx)
773    }
774
775    // -----------------------------------------------------------------------
776    // Manipulability measure
777    // -----------------------------------------------------------------------
778
779    /// Computes Yoshikawa's manipulability measure `sqrt(det(J J^T))`.
780    ///
781    /// High values indicate configurations far from singularities.
782    pub fn manipulability(&self, q: &[f64]) -> f64 {
783        let n = q.len();
784        let j = self.jacobian_analytical(q);
785        // Compute J J^T (3×3)
786        let mut a = [0.0f64; 9];
787        for r in 0..3 {
788            for c in 0..3 {
789                let mut s = 0.0f64;
790                for k in 0..n {
791                    s += j[r * n + k] * j[c * n + k];
792                }
793                a[r * 3 + c] = s;
794            }
795        }
796        mat3_det(&a).abs().sqrt()
797    }
798}
799
800// ---------------------------------------------------------------------------
801// 3×3 matrix helpers
802// ---------------------------------------------------------------------------
803
804/// Computes the determinant of a 3×3 row-major matrix.
805pub fn mat3_det(m: &[f64; 9]) -> f64 {
806    m[0] * (m[4] * m[8] - m[5] * m[7]) - m[1] * (m[3] * m[8] - m[5] * m[6])
807        + m[2] * (m[3] * m[7] - m[4] * m[6])
808}
809
810/// Computes the inverse of a 3×3 row-major matrix.
811///
812/// Returns the zero matrix if the determinant is near zero.
813pub fn mat3_inverse(m: &[f64; 9]) -> [f64; 9] {
814    let det = mat3_det(m);
815    if det.abs() < 1e-14 {
816        return [0.0; 9];
817    }
818    let inv_det = 1.0 / det;
819    [
820        (m[4] * m[8] - m[5] * m[7]) * inv_det,
821        (m[2] * m[7] - m[1] * m[8]) * inv_det,
822        (m[1] * m[5] - m[2] * m[4]) * inv_det,
823        (m[5] * m[6] - m[3] * m[8]) * inv_det,
824        (m[0] * m[8] - m[2] * m[6]) * inv_det,
825        (m[2] * m[3] - m[0] * m[5]) * inv_det,
826        (m[3] * m[7] - m[4] * m[6]) * inv_det,
827        (m[1] * m[6] - m[0] * m[7]) * inv_det,
828        (m[0] * m[4] - m[1] * m[3]) * inv_det,
829    ]
830}
831
832/// Transposes a 4×4 row-major matrix.
833pub fn mat4_transpose(m: &Mat4) -> Mat4 {
834    let mut t = [0.0f64; 16];
835    for r in 0..4 {
836        for c in 0..4 {
837            t[c * 4 + r] = m[r * 4 + c];
838        }
839    }
840    t
841}
842
843// ---------------------------------------------------------------------------
844// Geometry primitives for collision
845// ---------------------------------------------------------------------------
846
847/// Minimum distance from point `p` to line segment `[a, b]`.
848pub fn dist_point_segment(p: [f64; 3], a: [f64; 3], b: [f64; 3]) -> f64 {
849    let ab = sub3(b, a);
850    let ap = sub3(p, a);
851    let t = dot3(ap, ab) / (dot3(ab, ab) + 1e-30);
852    let t = t.clamp(0.0, 1.0);
853    let closest = add3(a, scale3(ab, t));
854    len3(sub3(p, closest))
855}
856
857/// Minimum distance between two line segments `[a1,b1]` and `[a2,b2]`.
858pub fn dist_segment_segment(a1: [f64; 3], b1: [f64; 3], a2: [f64; 3], b2: [f64; 3]) -> f64 {
859    let d1 = sub3(b1, a1);
860    let d2 = sub3(b2, a2);
861    let r = sub3(a1, a2);
862    let a = dot3(d1, d1);
863    let e = dot3(d2, d2);
864    let f = dot3(d2, r);
865    let eps = 1e-14;
866
867    let (s, t) = if a < eps && e < eps {
868        (0.0f64, 0.0f64)
869    } else if a < eps {
870        (0.0f64, (f / e).clamp(0.0, 1.0))
871    } else {
872        let c = dot3(d1, r);
873        if e < eps {
874            (((-c) / a).clamp(0.0, 1.0), 0.0)
875        } else {
876            let b = dot3(d1, d2);
877            let denom = a * e - b * b;
878            let s = if denom.abs() > eps {
879                ((b * f - c * e) / denom).clamp(0.0, 1.0)
880            } else {
881                0.0
882            };
883            let t = (b * s + f) / e;
884            if t < 0.0 {
885                let s = ((-c) / a).clamp(0.0, 1.0);
886                (s, 0.0f64)
887            } else if t > 1.0 {
888                let s = ((b - c) / a).clamp(0.0, 1.0);
889                (s, 1.0f64)
890            } else {
891                (s, t)
892            }
893        }
894    };
895    let c1 = add3(a1, scale3(d1, s));
896    let c2 = add3(a2, scale3(d2, t));
897    len3(sub3(c1, c2))
898}
899
900/// Tests whether a capsule (axis `[a,b]`, radius `r`) overlaps an AABB.
901pub fn capsule_aabb_overlap(
902    a: [f64; 3],
903    b: [f64; 3],
904    r: f64,
905    box_min: [f64; 3],
906    box_max: [f64; 3],
907) -> bool {
908    // Closest point on segment to AABB center
909    let center = [
910        (box_min[0] + box_max[0]) * 0.5,
911        (box_min[1] + box_max[1]) * 0.5,
912        (box_min[2] + box_max[2]) * 0.5,
913    ];
914    let half = [
915        (box_max[0] - box_min[0]) * 0.5,
916        (box_max[1] - box_min[1]) * 0.5,
917        (box_max[2] - box_min[2]) * 0.5,
918    ];
919    let ab = sub3(b, a);
920    let ac = sub3(center, a);
921    let t = (dot3(ac, ab) / (dot3(ab, ab) + 1e-30)).clamp(0.0, 1.0);
922    let closest_on_seg = add3(a, scale3(ab, t));
923    // Clamp to box
924    let mut dist_sq = 0.0f64;
925    for k in 0..3 {
926        let v = closest_on_seg[k];
927        let lo = box_min[k];
928        let hi = box_max[k];
929        if v < lo {
930            dist_sq += (lo - v) * (lo - v);
931        } else if v > hi {
932            dist_sq += (v - hi) * (v - hi);
933        }
934    }
935    let _ = half; // used implicitly via box_min/box_max
936    dist_sq <= r * r
937}
938
939// ---------------------------------------------------------------------------
940// Preset robot constructors
941// ---------------------------------------------------------------------------
942
943/// Builds a 3-DOF planar robot arm (all joints revolute, links in XY plane).
944///
945/// `lengths`: link lengths in metres.
946pub fn build_planar_3dof(lengths: [f64; 3]) -> RobotArm {
947    let lim = JointLimits::new(-PI, PI, 3.0, 50.0);
948    let links = vec![
949        RobotLink::new(
950            "link1",
951            DhParam::new(0.0, 0.0, lengths[0], 0.0),
952            JointType::Revolute,
953            Some(lim),
954            0.03,
955            lengths[0] * 0.5,
956        ),
957        RobotLink::new(
958            "link2",
959            DhParam::new(0.0, 0.0, lengths[1], 0.0),
960            JointType::Revolute,
961            Some(lim),
962            0.03,
963            lengths[1] * 0.5,
964        ),
965        RobotLink::new(
966            "link3",
967            DhParam::new(0.0, 0.0, lengths[2], 0.0),
968            JointType::Revolute,
969            Some(lim),
970            0.03,
971            lengths[2] * 0.5,
972        ),
973    ];
974    RobotArm::new("planar_3dof", links)
975}
976
977/// Builds a 6-DOF robot arm using standard Puma-like DH parameters.
978pub fn build_6dof_puma_like() -> RobotArm {
979    let lim_base = JointLimits::new(-PI, PI, 2.0, 150.0);
980    let lim_elbow = JointLimits::new(-PI * 0.75, PI * 0.75, 2.0, 100.0);
981    let lim_wrist = JointLimits::new(-PI, PI, 3.0, 50.0);
982
983    let links = vec![
984        RobotLink::new(
985            "joint1",
986            DhParam::new(0.0, 0.36, 0.0, PI / 2.0),
987            JointType::Revolute,
988            Some(lim_base),
989            0.06,
990            0.18,
991        ),
992        RobotLink::new(
993            "joint2",
994            DhParam::new(0.0, 0.0, 0.43, 0.0),
995            JointType::Revolute,
996            Some(lim_elbow),
997            0.05,
998            0.215,
999        ),
1000        RobotLink::new(
1001            "joint3",
1002            DhParam::new(0.0, 0.0, 0.0, PI / 2.0),
1003            JointType::Revolute,
1004            Some(lim_elbow),
1005            0.05,
1006            0.05,
1007        ),
1008        RobotLink::new(
1009            "joint4",
1010            DhParam::new(0.0, 0.43, 0.0, -PI / 2.0),
1011            JointType::Revolute,
1012            Some(lim_wrist),
1013            0.04,
1014            0.215,
1015        ),
1016        RobotLink::new(
1017            "joint5",
1018            DhParam::new(0.0, 0.0, 0.0, PI / 2.0),
1019            JointType::Revolute,
1020            Some(lim_wrist),
1021            0.04,
1022            0.04,
1023        ),
1024        RobotLink::new(
1025            "joint6",
1026            DhParam::new(0.0, 0.1, 0.0, 0.0),
1027            JointType::Revolute,
1028            Some(lim_wrist),
1029            0.03,
1030            0.05,
1031        ),
1032    ];
1033    RobotArm::new("puma_6dof", links)
1034}
1035
1036// ---------------------------------------------------------------------------
1037// URDF-like robot description (from struct)
1038// ---------------------------------------------------------------------------
1039
1040/// A joint in a URDF-like robot description.
1041#[derive(Debug, Clone)]
1042pub struct UrdfJoint {
1043    /// Joint name.
1044    pub name: String,
1045    /// Parent link name.
1046    pub parent: String,
1047    /// Child link name.
1048    pub child: String,
1049    /// Joint type.
1050    pub joint_type: JointType,
1051    /// Joint origin XYZ.
1052    pub origin_xyz: [f64; 3],
1053    /// Joint origin RPY.
1054    pub origin_rpy: [f64; 3],
1055    /// Joint axis.
1056    pub axis: [f64; 3],
1057    /// Optional limits.
1058    pub limits: Option<JointLimits>,
1059}
1060
1061/// A link in a URDF-like description.
1062#[derive(Debug, Clone)]
1063pub struct UrdfLink {
1064    /// Link name.
1065    pub name: String,
1066    /// Collision geometry radius (simplified capsule).
1067    pub collision_radius: f64,
1068    /// Collision geometry half-length.
1069    pub collision_half_len: f64,
1070}
1071
1072/// A complete URDF-like robot description parsed from structs.
1073#[derive(Debug, Clone)]
1074pub struct UrdfRobot {
1075    /// Robot name.
1076    pub name: String,
1077    /// All links.
1078    pub links: Vec<UrdfLink>,
1079    /// All joints.
1080    pub joints: Vec<UrdfJoint>,
1081}
1082
1083impl UrdfRobot {
1084    /// Converts this URDF-like description into a `RobotArm`.
1085    ///
1086    /// Only supports simple serial chains (first joint chain from base).
1087    pub fn to_robot_arm(&self) -> RobotArm {
1088        let arm_links: Vec<RobotLink> = self
1089            .joints
1090            .iter()
1091            .map(|jt| {
1092                let collision = self
1093                    .links
1094                    .iter()
1095                    .find(|l| l.name == jt.child)
1096                    .map(|l| (l.collision_radius, l.collision_half_len))
1097                    .unwrap_or((0.05, 0.1));
1098                // Build DH-approximated transform from RPY + XYZ
1099                let dh = rpy_xyz_to_dh(jt.origin_rpy, jt.origin_xyz);
1100                RobotLink::new(
1101                    &jt.name,
1102                    dh,
1103                    jt.joint_type,
1104                    jt.limits,
1105                    collision.0,
1106                    collision.1,
1107                )
1108            })
1109            .collect();
1110        RobotArm::new(&self.name, arm_links)
1111    }
1112}
1113
1114/// Approximates DH parameters from RPY and XYZ joint origin data.
1115///
1116/// This is a simplified mapping; full DH extraction requires more link data.
1117fn rpy_xyz_to_dh(rpy: [f64; 3], xyz: [f64; 3]) -> DhParam {
1118    let a = (xyz[0] * xyz[0] + xyz[1] * xyz[1]).sqrt();
1119    DhParam::new(rpy[2], xyz[2], a, rpy[0])
1120}
1121
1122// ---------------------------------------------------------------------------
1123// Velocity and acceleration analysis
1124// ---------------------------------------------------------------------------
1125
1126/// Computes the end-effector Cartesian velocity given joint velocities `dq`.
1127///
1128/// `v = J * dq`  (3-vector)
1129pub fn end_effector_velocity(jacobian: &[f64], dq: &[f64]) -> [f64; 3] {
1130    let n = dq.len();
1131    let mut v = [0.0f64; 3];
1132    for row in 0..3 {
1133        for k in 0..n {
1134            v[row] += jacobian[row * n + k] * dq[k];
1135        }
1136    }
1137    v
1138}
1139
1140/// Computes the minimum norm joint velocity for a desired Cartesian velocity.
1141///
1142/// Uses the damped pseudo-inverse: `dq = J^+ * v`.
1143pub fn joint_velocity_from_cartesian(
1144    jacobian: &[f64],
1145    n_dof: usize,
1146    v_desired: [f64; 3],
1147    lambda: f64,
1148) -> Vec<f64> {
1149    let jp = RobotArm::jacobian_pseudoinverse(jacobian, n_dof, lambda);
1150    let mut dq = vec![0.0f64; n_dof];
1151    for i in 0..n_dof {
1152        dq[i] =
1153            jp[i * 3] * v_desired[0] + jp[i * 3 + 1] * v_desired[1] + jp[i * 3 + 2] * v_desired[2];
1154    }
1155    dq
1156}
1157
1158// ---------------------------------------------------------------------------
1159// Singularity analysis
1160// ---------------------------------------------------------------------------
1161
1162/// Classifies the manipulability of a configuration.
1163#[derive(Debug, Clone, Copy, PartialEq)]
1164pub enum SingularityClass {
1165    /// Far from any singularity.
1166    Regular,
1167    /// Close to (but not at) a singularity.
1168    NearSingular,
1169    /// At or very close to a type-1 (boundary) singularity.
1170    BoundarySingular,
1171    /// At or very close to a type-2 (interior) singularity.
1172    InteriorSingular,
1173}
1174
1175/// Classifies the singularity of a configuration from its manipulability value.
1176pub fn classify_singularity(manipulability: f64) -> SingularityClass {
1177    if manipulability < 1e-6 {
1178        SingularityClass::InteriorSingular
1179    } else if manipulability < 1e-3 {
1180        SingularityClass::BoundarySingular
1181    } else if manipulability < 0.05 {
1182        SingularityClass::NearSingular
1183    } else {
1184        SingularityClass::Regular
1185    }
1186}
1187
1188// ---------------------------------------------------------------------------
1189// Condition number of Jacobian
1190// ---------------------------------------------------------------------------
1191
1192/// Estimates the condition number of the `3×n` Jacobian via the ratio of
1193/// the largest to smallest singular value approximation.
1194///
1195/// Uses power iteration to find the largest and smallest "effective"
1196/// singular values via `J J^T`.
1197pub fn jacobian_condition_number(jacobian: &[f64], n: usize) -> f64 {
1198    // Form J J^T (3×3)
1199    let mut a = [0.0f64; 9];
1200    for r in 0..3 {
1201        for c in 0..3 {
1202            let mut s = 0.0f64;
1203            for k in 0..n {
1204                s += jacobian[r * n + k] * jacobian[c * n + k];
1205            }
1206            a[r * 3 + c] = s;
1207        }
1208    }
1209    // Frobenius norm as proxy for max singular value (fast approximation)
1210    let frob: f64 = a.iter().map(|x| x * x).sum::<f64>().sqrt();
1211    let det = mat3_det(&a).abs();
1212    if frob < 1e-14 {
1213        return f64::INFINITY;
1214    }
1215    // min_sv ≈ det / (frob^2) for 3×3 case
1216    let min_sv_approx = det / (frob * frob + 1e-30);
1217    frob / (min_sv_approx + 1e-30)
1218}
1219
1220// ---------------------------------------------------------------------------
1221// Path planning helpers
1222// ---------------------------------------------------------------------------
1223
1224/// Generates a linear interpolation path in joint space.
1225///
1226/// Returns `n_steps + 1` joint configurations from `q_start` to `q_end`.
1227pub fn joint_space_path(q_start: &[f64], q_end: &[f64], n_steps: usize) -> Vec<Vec<f64>> {
1228    let n_steps = n_steps.max(1);
1229    (0..=n_steps)
1230        .map(|step| {
1231            let t = step as f64 / n_steps as f64;
1232            q_start
1233                .iter()
1234                .zip(q_end.iter())
1235                .map(|(a, b)| a + t * (b - a))
1236                .collect()
1237        })
1238        .collect()
1239}
1240
1241/// Checks whether every configuration along a straight-line joint-space path
1242/// is within joint limits and collision-free with respect to a sphere obstacle.
1243pub fn path_collision_free(
1244    arm: &RobotArm,
1245    path: &[Vec<f64>],
1246    sphere_center: [f64; 3],
1247    sphere_radius: f64,
1248) -> bool {
1249    for q in path {
1250        if arm.check_collision_sphere(q, sphere_center, sphere_radius) {
1251            return false;
1252        }
1253        if !arm.joints_within_limits(q) {
1254            return false;
1255        }
1256    }
1257    true
1258}
1259
1260// ---------------------------------------------------------------------------
1261// Torque / statics analysis
1262// ---------------------------------------------------------------------------
1263
1264/// Computes the joint torques required to produce a given end-effector force.
1265///
1266/// Uses the static relationship `τ = J^T f`.
1267pub fn joint_torques_from_force(jacobian: &[f64], n_dof: usize, force: [f64; 3]) -> Vec<f64> {
1268    let mut tau = vec![0.0f64; n_dof];
1269    for i in 0..n_dof {
1270        tau[i] = jacobian[i] * force[0]
1271            + jacobian[n_dof + i] * force[1]
1272            + jacobian[2 * n_dof + i] * force[2];
1273    }
1274    tau
1275}
1276
1277// ---------------------------------------------------------------------------
1278// Reachability map
1279// ---------------------------------------------------------------------------
1280
1281/// A coarse voxel reachability map for a robot arm.
1282///
1283/// Populated by Monte-Carlo workspace sampling.
1284#[derive(Debug, Clone)]
1285pub struct ReachabilityMap {
1286    /// World-space origin of the voxel grid.
1287    pub origin: [f64; 3],
1288    /// Size of each voxel in metres.
1289    pub voxel_size: f64,
1290    /// Number of voxels along each axis.
1291    pub dims: [usize; 3],
1292    /// Flat array of occupancy counts.
1293    pub counts: Vec<u32>,
1294}
1295
1296impl ReachabilityMap {
1297    /// Creates an empty reachability map.
1298    pub fn new(origin: [f64; 3], voxel_size: f64, dims: [usize; 3]) -> Self {
1299        let total = dims[0] * dims[1] * dims[2];
1300        Self {
1301            origin,
1302            voxel_size,
1303            dims,
1304            counts: vec![0u32; total],
1305        }
1306    }
1307
1308    /// Converts a world position to a voxel index, returning `None` if outside.
1309    pub fn world_to_voxel(&self, p: [f64; 3]) -> Option<[usize; 3]> {
1310        let mut idx = [0usize; 3];
1311        for k in 0..3 {
1312            let v = (p[k] - self.origin[k]) / self.voxel_size;
1313            if v < 0.0 || v >= self.dims[k] as f64 {
1314                return None;
1315            }
1316            idx[k] = v as usize;
1317        }
1318        Some(idx)
1319    }
1320
1321    /// Increments the occupancy count for the voxel at world position `p`.
1322    pub fn add_point(&mut self, p: [f64; 3]) {
1323        if let Some(idx) = self.world_to_voxel(p) {
1324            let flat = idx[0] * self.dims[1] * self.dims[2] + idx[1] * self.dims[2] + idx[2];
1325            self.counts[flat] = self.counts[flat].saturating_add(1);
1326        }
1327    }
1328
1329    /// Populates the map from a Monte-Carlo workspace sample.
1330    pub fn populate(&mut self, arm: &RobotArm, n_samples: usize) {
1331        let pts = arm.workspace_monte_carlo(n_samples);
1332        for p in pts {
1333            self.add_point(p);
1334        }
1335    }
1336
1337    /// Returns the total number of non-zero voxels (reachable cells).
1338    pub fn reachable_count(&self) -> usize {
1339        self.counts.iter().filter(|&&c| c > 0).count()
1340    }
1341}
1342
1343// ---------------------------------------------------------------------------
1344// Unit tests
1345// ---------------------------------------------------------------------------
1346
1347#[cfg(test)]
1348mod tests {
1349    use super::*;
1350
1351    fn planar_arm() -> RobotArm {
1352        build_planar_3dof([0.5, 0.4, 0.3])
1353    }
1354
1355    fn puma_arm() -> RobotArm {
1356        build_6dof_puma_like()
1357    }
1358
1359    // --- DH parameter tests ---
1360
1361    #[test]
1362    fn test_dh_identity_at_zero() {
1363        let dh = DhParam::new(0.0, 0.0, 0.0, 0.0);
1364        let t = dh.transform();
1365        let ident = mat4_identity();
1366        for i in 0..16 {
1367            assert!(
1368                (t[i] - ident[i]).abs() < 1e-12,
1369                "DH identity mismatch at {i}"
1370            );
1371        }
1372    }
1373
1374    #[test]
1375    fn test_dh_pure_translation_a() {
1376        let a = 1.0;
1377        let dh = DhParam::new(0.0, 0.0, a, 0.0);
1378        let t = dh.transform();
1379        // Should translate by a along X
1380        assert!((t[3] - a).abs() < 1e-12);
1381        assert!(t[7].abs() < 1e-12);
1382        assert!(t[11].abs() < 1e-12);
1383    }
1384
1385    #[test]
1386    fn test_dh_pure_translation_d() {
1387        let d = 2.0;
1388        let dh = DhParam::new(0.0, d, 0.0, 0.0);
1389        let t = dh.transform();
1390        // Should translate by d along Z
1391        assert!((t[11] - d).abs() < 1e-12);
1392    }
1393
1394    #[test]
1395    fn test_dh_rotation_theta() {
1396        let dh = DhParam::new(PI / 2.0, 0.0, 0.0, 0.0);
1397        let t = dh.transform();
1398        // Rz(pi/2): cos -> 0, sin -> 1
1399        assert!(t[0].abs() < 1e-12); // cos
1400        assert!((t[1] + 1.0).abs() < 1e-12); // -sin
1401        assert!((t[4] - 1.0).abs() < 1e-12); // sin
1402    }
1403
1404    #[test]
1405    fn test_mat4_mul_identity() {
1406        let ident = mat4_identity();
1407        let a = rot_z(0.5);
1408        let result = mat4_mul(&a, &ident);
1409        for i in 0..16 {
1410            assert!(
1411                (result[i] - a[i]).abs() < 1e-12,
1412                "mul ident mismatch at {i}"
1413            );
1414        }
1415    }
1416
1417    #[test]
1418    fn test_mat4_transpose_roundtrip() {
1419        let m = rot_z(1.2);
1420        let tt = mat4_transpose(&mat4_transpose(&m));
1421        for i in 0..16 {
1422            assert!((tt[i] - m[i]).abs() < 1e-12);
1423        }
1424    }
1425
1426    // --- Forward kinematics ---
1427
1428    #[test]
1429    fn test_fk_zero_config_planar_x() {
1430        let arm = planar_arm();
1431        let q = vec![0.0f64; 3];
1432        let pos = arm.end_effector_pos(&q);
1433        // All links along X: pos ~ [0.5+0.4+0.3, 0, 0]
1434        assert!((pos[0] - 1.2).abs() < 1e-10);
1435        assert!(pos[1].abs() < 1e-10);
1436        assert!(pos[2].abs() < 1e-10);
1437    }
1438
1439    #[test]
1440    fn test_fk_pi_fold() {
1441        let arm = planar_arm();
1442        // With q2 = PI, end-effector folds back
1443        let q = vec![0.0, PI, 0.0];
1444        let pos = arm.end_effector_pos(&q);
1445        // link1(0.5) + link2*cos(pi)(-0.4) + link3*cos(pi)(-0.3) ≈ 0.5-0.4-0.3 = -0.2
1446        assert!((pos[0] - (-0.2)).abs() < 1e-8);
1447    }
1448
1449    #[test]
1450    fn test_fk_frames_count() {
1451        let arm = planar_arm();
1452        let q = vec![0.0f64; 3];
1453        let frames = arm.forward_frames(&q);
1454        // 1 base + 3 links = 4
1455        assert_eq!(frames.len(), 4);
1456    }
1457
1458    #[test]
1459    fn test_dof_count() {
1460        let arm = planar_arm();
1461        assert_eq!(arm.dof(), 3);
1462    }
1463
1464    #[test]
1465    fn test_dof_puma() {
1466        let arm = puma_arm();
1467        assert_eq!(arm.dof(), 6);
1468    }
1469
1470    // --- Joint limits ---
1471
1472    #[test]
1473    fn test_joint_limits_within() {
1474        let lim = JointLimits::new(-PI, PI, 3.0, 50.0);
1475        assert!(lim.is_within(0.0));
1476        assert!(lim.is_within(-PI));
1477        assert!(lim.is_within(PI));
1478        assert!(!lim.is_within(PI + 0.001));
1479    }
1480
1481    #[test]
1482    fn test_joint_limits_clamp() {
1483        let lim = JointLimits::new(-1.0, 1.0, 1.0, 10.0);
1484        assert_eq!(lim.clamp(2.0), 1.0);
1485        assert_eq!(lim.clamp(-2.0), -1.0);
1486        assert_eq!(lim.clamp(0.5), 0.5);
1487    }
1488
1489    #[test]
1490    fn test_joints_within_limits_planar() {
1491        let arm = planar_arm();
1492        assert!(arm.joints_within_limits(&[0.0, 0.0, 0.0]));
1493        assert!(!arm.joints_within_limits(&[4.0, 0.0, 0.0])); // > PI
1494    }
1495
1496    #[test]
1497    fn test_clamp_joints_planar() {
1498        let arm = planar_arm();
1499        let clamped = arm.clamp_joints(&[5.0, -5.0, 0.0]);
1500        assert!((clamped[0] - PI).abs() < 1e-12);
1501        assert!((clamped[1] - (-PI)).abs() < 1e-12);
1502    }
1503
1504    // --- Jacobian tests ---
1505
1506    #[test]
1507    fn test_jacobian_numerical_size() {
1508        let arm = planar_arm();
1509        let q = vec![0.1, 0.2, 0.3];
1510        let j = arm.jacobian_numerical(&q);
1511        assert_eq!(j.len(), 3 * 3);
1512    }
1513
1514    #[test]
1515    fn test_jacobian_analytical_matches_numerical() {
1516        let arm = planar_arm();
1517        let q = vec![0.2, -0.3, 0.5];
1518        let jn = arm.jacobian_numerical(&q);
1519        let ja = arm.jacobian_analytical(&q);
1520        for i in 0..9 {
1521            assert!(
1522                (jn[i] - ja[i]).abs() < 1e-4,
1523                "Jacobian mismatch at {i}: numerical={} analytical={}",
1524                jn[i],
1525                ja[i]
1526            );
1527        }
1528    }
1529
1530    #[test]
1531    fn test_jacobian_pseudoinverse_identity_check() {
1532        // For a simple scalar case: J = [1,0,0; 0,1,0; 0,0,1], pinv = identity
1533        let j: Vec<f64> = vec![1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
1534        let jp = RobotArm::jacobian_pseudoinverse(&j, 3, 0.0001);
1535        // jp should be approximately identity (3x3 = n=3, result is 3x3 n×3)
1536        assert!((jp[0] - 1.0).abs() < 1e-3);
1537        assert!((jp[4] - 1.0).abs() < 1e-3);
1538        assert!((jp[8] - 1.0).abs() < 1e-3);
1539    }
1540
1541    // --- IK tests ---
1542
1543    #[test]
1544    fn test_ik_reachable_target() {
1545        let arm = planar_arm();
1546        let target = [0.8, 0.4, 0.0];
1547        let q_init = vec![0.1f64, 0.2, 0.1];
1548        let result = arm.ik_newton_raphson(target, &q_init, 200, 1e-6, 0.01);
1549        let q = match result {
1550            Ok(q) | Err(q) => q,
1551        };
1552        let pos = arm.end_effector_pos(&q);
1553        assert!(
1554            len3(sub3(target, pos)) < 0.05,
1555            "IK did not converge close enough"
1556        );
1557    }
1558
1559    #[test]
1560    fn test_ik_at_zero_config() {
1561        let arm = planar_arm();
1562        let target = arm.end_effector_pos(&[0.0, 0.0, 0.0]);
1563        let q_init = vec![0.01f64, 0.01, 0.01];
1564        let result = arm.ik_newton_raphson(target, &q_init, 200, 1e-8, 0.001);
1565        let q = match result {
1566            Ok(q) | Err(q) => q,
1567        };
1568        let pos = arm.end_effector_pos(&q);
1569        assert!(len3(sub3(target, pos)) < 1e-4);
1570    }
1571
1572    // --- Collision tests ---
1573
1574    #[test]
1575    fn test_dist_point_segment_basic() {
1576        let d = dist_point_segment([0.0, 1.0, 0.0], [0.0, 0.0, 0.0], [1.0, 0.0, 0.0]);
1577        assert!((d - 1.0).abs() < 1e-12);
1578    }
1579
1580    #[test]
1581    fn test_dist_point_segment_on_segment() {
1582        let d = dist_point_segment([0.5, 0.0, 0.0], [0.0, 0.0, 0.0], [1.0, 0.0, 0.0]);
1583        assert!(d.abs() < 1e-12);
1584    }
1585
1586    #[test]
1587    fn test_collision_sphere_no_hit() {
1588        let arm = planar_arm();
1589        let q = vec![0.0f64; 3];
1590        // Sphere far away
1591        let hit = arm.check_collision_sphere(&q, [10.0, 10.0, 10.0], 0.01);
1592        assert!(!hit);
1593    }
1594
1595    #[test]
1596    fn test_collision_sphere_hit() {
1597        let arm = planar_arm();
1598        let q = vec![0.0f64; 3];
1599        // Sphere at robot base position
1600        let hit = arm.check_collision_sphere(&q, [0.25, 0.0, 0.0], 0.3);
1601        assert!(hit);
1602    }
1603
1604    #[test]
1605    fn test_self_collision_straight() {
1606        let arm = planar_arm();
1607        // Straight configuration — no self-collision expected
1608        let q = vec![0.0f64; 3];
1609        assert!(!arm.check_self_collision(&q));
1610    }
1611
1612    #[test]
1613    fn test_capsule_aabb_no_overlap() {
1614        let no_overlap = capsule_aabb_overlap(
1615            [10.0, 0.0, 0.0],
1616            [11.0, 0.0, 0.0],
1617            0.1,
1618            [-1.0, -1.0, -1.0],
1619            [1.0, 1.0, 1.0],
1620        );
1621        assert!(!no_overlap);
1622    }
1623
1624    #[test]
1625    fn test_capsule_aabb_overlap() {
1626        let overlap = capsule_aabb_overlap(
1627            [-0.5, 0.0, 0.0],
1628            [0.5, 0.0, 0.0],
1629            0.1,
1630            [-1.0, -1.0, -1.0],
1631            [1.0, 1.0, 1.0],
1632        );
1633        assert!(overlap);
1634    }
1635
1636    // --- Swept volume ---
1637
1638    #[test]
1639    fn test_swept_volume_contains_endpoints() {
1640        let arm = planar_arm();
1641        let q0 = vec![0.0f64; 3];
1642        let q1 = vec![PI / 4.0, PI / 4.0, PI / 4.0];
1643        let (mn, mx) = arm.swept_volume_aabb(&q0, &q1, 20);
1644        let p0 = arm.end_effector_pos(&q0);
1645        let p1 = arm.end_effector_pos(&q1);
1646        for k in 0..3 {
1647            assert!(p0[k] >= mn[k] - 1e-9 && p0[k] <= mx[k] + 1e-9);
1648            assert!(p1[k] >= mn[k] - 1e-9 && p1[k] <= mx[k] + 1e-9);
1649        }
1650    }
1651
1652    // --- Workspace ---
1653
1654    #[test]
1655    fn test_workspace_monte_carlo_count() {
1656        let arm = planar_arm();
1657        let pts = arm.workspace_monte_carlo(100);
1658        assert_eq!(pts.len(), 100);
1659    }
1660
1661    #[test]
1662    fn test_workspace_bounding_box_some() {
1663        let arm = planar_arm();
1664        let bb = arm.workspace_bounding_box(200);
1665        assert!(bb.is_some());
1666        let (mn, mx) = bb.unwrap();
1667        for k in 0..3 {
1668            assert!(mn[k] <= mx[k]);
1669        }
1670    }
1671
1672    // --- Manipulability ---
1673
1674    #[test]
1675    fn test_manipulability_positive_planar() {
1676        let arm = planar_arm();
1677        let m = arm.manipulability(&[0.3, 0.4, 0.5]);
1678        assert!(m >= 0.0);
1679    }
1680
1681    #[test]
1682    fn test_manipulability_near_singular() {
1683        let arm = planar_arm();
1684        // Fully extended: all q = 0 → rank-deficient in 2D embedded in 3D
1685        let m = arm.manipulability(&[0.0, 0.0, 0.0]);
1686        // In 3D, planar arm has zero z-component, so det can be 0
1687        assert!(m >= 0.0);
1688    }
1689
1690    #[test]
1691    fn test_singularity_classification() {
1692        assert_eq!(
1693            classify_singularity(0.0),
1694            SingularityClass::InteriorSingular
1695        );
1696        assert_eq!(classify_singularity(0.1), SingularityClass::Regular);
1697        assert_eq!(
1698            classify_singularity(1e-4),
1699            SingularityClass::BoundarySingular
1700        );
1701        assert_eq!(classify_singularity(0.01), SingularityClass::NearSingular);
1702    }
1703
1704    // --- Reachability map ---
1705
1706    #[test]
1707    fn test_reachability_map_populate() {
1708        let arm = planar_arm();
1709        let mut rmap = ReachabilityMap::new([-2.0, -2.0, -0.1], 0.1, [40, 40, 2]);
1710        rmap.populate(&arm, 500);
1711        assert!(rmap.reachable_count() > 0);
1712    }
1713
1714    #[test]
1715    fn test_reachability_map_voxel_oob() {
1716        let rmap = ReachabilityMap::new([0.0, 0.0, 0.0], 0.1, [10, 10, 10]);
1717        assert!(rmap.world_to_voxel([1.5, 0.5, 0.5]).is_none());
1718    }
1719
1720    // --- Path planning helpers ---
1721
1722    #[test]
1723    fn test_joint_space_path_length() {
1724        let path = joint_space_path(&[0.0, 0.0], &[1.0, 1.0], 10);
1725        assert_eq!(path.len(), 11);
1726    }
1727
1728    #[test]
1729    fn test_joint_space_path_endpoints() {
1730        let q0 = vec![0.0f64, 0.5];
1731        let q1 = vec![1.0f64, -0.5];
1732        let path = joint_space_path(&q0, &q1, 5);
1733        for (a, b) in path[0].iter().zip(q0.iter()) {
1734            assert!((a - b).abs() < 1e-12);
1735        }
1736        for (a, b) in path.last().unwrap().iter().zip(q1.iter()) {
1737            assert!((a - b).abs() < 1e-12);
1738        }
1739    }
1740
1741    // --- Torque analysis ---
1742
1743    #[test]
1744    fn test_joint_torques_zero_force() {
1745        let arm = planar_arm();
1746        let q = vec![0.0f64; 3];
1747        let j = arm.jacobian_analytical(&q);
1748        let tau = joint_torques_from_force(&j, 3, [0.0, 0.0, 0.0]);
1749        for t in &tau {
1750            assert!(t.abs() < 1e-12);
1751        }
1752    }
1753
1754    // --- URDF-like struct ---
1755
1756    #[test]
1757    fn test_urdf_to_robot_arm() {
1758        let robot = UrdfRobot {
1759            name: "test_bot".into(),
1760            links: vec![
1761                UrdfLink {
1762                    name: "base".into(),
1763                    collision_radius: 0.05,
1764                    collision_half_len: 0.1,
1765                },
1766                UrdfLink {
1767                    name: "link1".into(),
1768                    collision_radius: 0.04,
1769                    collision_half_len: 0.2,
1770                },
1771            ],
1772            joints: vec![UrdfJoint {
1773                name: "joint1".into(),
1774                parent: "base".into(),
1775                child: "link1".into(),
1776                joint_type: JointType::Revolute,
1777                origin_xyz: [0.0, 0.0, 0.2],
1778                origin_rpy: [0.0, 0.0, 0.0],
1779                axis: [0.0, 0.0, 1.0],
1780                limits: Some(JointLimits::new(-PI, PI, 2.0, 50.0)),
1781            }],
1782        };
1783        let arm = robot.to_robot_arm();
1784        assert_eq!(arm.links.len(), 1);
1785        assert_eq!(arm.dof(), 1);
1786    }
1787
1788    // --- Velocity from Cartesian ---
1789
1790    #[test]
1791    fn test_end_effector_velocity_zero_dq() {
1792        let arm = planar_arm();
1793        let q = vec![0.2f64, 0.3, 0.1];
1794        let j = arm.jacobian_analytical(&q);
1795        let v = end_effector_velocity(&j, &[0.0, 0.0, 0.0]);
1796        for vi in &v {
1797            assert!(vi.abs() < 1e-12);
1798        }
1799    }
1800
1801    #[test]
1802    fn test_joint_velocity_from_cartesian_zero() {
1803        let arm = planar_arm();
1804        let q = vec![0.2f64, 0.3, 0.1];
1805        let j = arm.jacobian_analytical(&q);
1806        let dq = joint_velocity_from_cartesian(&j, 3, [0.0, 0.0, 0.0], 0.01);
1807        for d in &dq {
1808            assert!(d.abs() < 1e-10);
1809        }
1810    }
1811
1812    // --- Segment–segment distance ---
1813
1814    #[test]
1815    fn test_segment_segment_parallel() {
1816        let d = dist_segment_segment(
1817            [0.0, 0.0, 0.0],
1818            [1.0, 0.0, 0.0],
1819            [0.0, 1.0, 0.0],
1820            [1.0, 1.0, 0.0],
1821        );
1822        assert!((d - 1.0).abs() < 1e-10);
1823    }
1824
1825    #[test]
1826    fn test_segment_segment_crossing() {
1827        let d = dist_segment_segment(
1828            [-1.0, 0.0, 0.0],
1829            [1.0, 0.0, 0.0],
1830            [0.0, -1.0, 0.5],
1831            [0.0, 1.0, 0.5],
1832        );
1833        // Distance should be 0.5 (along Z)
1834        assert!((d - 0.5).abs() < 1e-10);
1835    }
1836
1837    // --- mat3 helpers ---
1838
1839    #[test]
1840    fn test_mat3_det_identity() {
1841        let ident = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
1842        assert!((mat3_det(&ident) - 1.0).abs() < 1e-12);
1843    }
1844
1845    #[test]
1846    fn test_mat3_inverse_identity() {
1847        let ident = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
1848        let inv = mat3_inverse(&ident);
1849        for i in 0..9 {
1850            assert!((inv[i] - ident[i]).abs() < 1e-12);
1851        }
1852    }
1853
1854    #[test]
1855    fn test_mat3_inverse_roundtrip() {
1856        let m = [2.0, 1.0, 0.0, 0.5, 3.0, 1.0, 0.0, 1.0, 4.0];
1857        let inv = mat3_inverse(&m);
1858        // m * inv should be identity
1859        for r in 0..3 {
1860            for c in 0..3 {
1861                let mut s = 0.0f64;
1862                for k in 0..3 {
1863                    s += m[r * 3 + k] * inv[k * 3 + c];
1864                }
1865                let expected = if r == c { 1.0 } else { 0.0 };
1866                assert!((s - expected).abs() < 1e-10, "m*inv[{r},{c}]={s}");
1867            }
1868        }
1869    }
1870}