Skip to main content

brep_cam/kinematics/
mod.rs

1//! Machine kinematics (lane C10, row 1): forward and inverse kinematics of
2//! 3-, 4- and 5-axis mills from the shared [`Machine`] definition.
3//!
4//! Conventions (sections 1-3):
5//! - A cutter-location point is the tool tip `p` plus the unit tool axis `a`
6//!   (tip toward spindle), both in the SETUP frame S. `None` axis is `+Z`.
7//! - A rotary joint `(d, o, θ)` rotates by `θ` (right-hand rule about the unit
8//!   direction `d`) about the line through `o`. A TABLE joint rotates the part,
9//!   a HEAD joint rotates the tool. The primary joint carries the secondary.
10//! - Head pivots are relative to the controlled point (tool tip at zero
11//!   angles); table pivots are in S.
12//! - Angles are degrees in the API and radians inside. Controller sign
13//!   conventions belong to the post.
14//!
15//! Every chain reduces to one inverse problem, [`two_axis`]: rotate `u` first
16//! about `d_a` by `α`, then about `d_b` by `β`, to reach `w`. No orthogonality
17//! between the axes is assumed, so nutating heads solve with the same code.
18//! Both solution branches are returned; a free angle inside the singular cone
19//! is held at the previous value and tagged `singular`.
20
21use crate::model::{Kinematics, Machine, MachineKind, Point3, RigidTransform, RotaryAxis};
22use serde::{Deserialize, Serialize};
23use std::f64::consts::PI;
24
25pub mod library;
26pub mod post;
27pub mod sequence;
28
29/// Which side of the machine a rotary joint moves.
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum Carrier {
32    /// Rotates the part.
33    Table,
34    /// Rotates the tool.
35    Head,
36}
37
38/// One rotary joint of a chain.
39#[derive(Clone, Debug, PartialEq)]
40pub struct Joint {
41    /// Axis letter the post emits.
42    pub name: String,
43    /// Unit rotation direction.
44    pub direction: Point3,
45    /// A point on the axis: in S for table joints, relative to the controlled
46    /// point for head joints.
47    pub origin: Point3,
48    /// Angular limits in degrees (`None`: continuous).
49    pub limits_deg: Option<[f64; 2]>,
50    pub carrier: Carrier,
51}
52
53/// A rigid link of the machine that solids attach to: the model's type, re-exported
54/// so machine definitions and the chain share one `Link`.
55pub use crate::model::Link;
56
57/// The kinematic arrangement, with the joints in output order.
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub enum ChainKind {
60    /// No rotary joint; only `+Z` tool axes are reachable.
61    Mill3,
62    /// One table rotary (`joints[0]`).
63    Mill4,
64    /// `joints[0]` primary table carries `joints[1]` secondary table.
65    TableTable,
66    /// `joints[0]` primary head carries `joints[1]` secondary head (spindle).
67    HeadHead,
68    /// `joints[0]` head tilts the tool, `joints[1]` table turns the part.
69    HeadTable,
70}
71
72/// A machine's kinematic chain.
73#[derive(Clone, Debug, PartialEq)]
74pub struct Chain {
75    pub kind: ChainKind,
76    pub joints: Vec<Joint>,
77}
78
79/// Solver settings.
80#[derive(Clone, Copy, Debug, PartialEq)]
81pub struct KinematicsOptions {
82    /// Rotary tool-centre-point control (G43.4 / TRAORI / M128): linear words
83    /// are the tip in S. Off: linear words are the machine axes with the
84    /// pivots pushed through the chain.
85    pub tcp: bool,
86    /// Half-angle of the cone about a joint's direction inside which the
87    /// tool axis leaves that joint's angle free (the near-vertical C problem).
88    pub singular_cone_deg: f64,
89    /// Reachability tolerance on direction cosines (a 4-axis tool axis that
90    /// misses the rotary's plane by more than this is refused).
91    pub cos_eps: f64,
92}
93
94impl Default for KinematicsOptions {
95    fn default() -> Self {
96        Self { tcp: true, singular_cone_deg: 0.5, cos_eps: 1e-6 }
97    }
98}
99
100impl KinematicsOptions {
101    pub fn validate(&self) -> Result<(), KinematicsError> {
102        if !self.singular_cone_deg.is_finite() || self.singular_cone_deg < 0.0 || self.singular_cone_deg >= 90.0 {
103            return Err(KinematicsError::InvalidInput("singular cone must be in [0, 90) degrees".into()));
104        }
105        if !self.cos_eps.is_finite() || self.cos_eps < 0.0 || self.cos_eps > 1e-3 {
106            return Err(KinematicsError::InvalidInput("cosine tolerance must be in [0, 1e-3]".into()));
107        }
108        Ok(())
109    }
110}
111
112/// One machine configuration reaching a CL point.
113#[derive(Clone, Debug, PartialEq)]
114pub struct Pose {
115    /// Linear axis words: the tip in S with TCP on, the machine axes with TCP off.
116    pub linear: Point3,
117    /// Rotary angles in degrees, in `Chain::joints` order, in `(-180, 180]`
118    /// unless the sequence solver unwound them.
119    pub angles_deg: Vec<f64>,
120    /// Solution branch (0 or 1) of the inverse problem.
121    pub branch: u8,
122    /// A free angle was held at the previous (or zero) value.
123    pub singular: bool,
124}
125
126#[derive(Clone, Debug, PartialEq)]
127pub enum KinematicsError {
128    /// The machine definition cannot be turned into a chain.
129    InvalidMachine(String),
130    InvalidInput(String),
131    /// The tool axis is not finite or zero.
132    InvalidAxis,
133    /// No joint configuration reaches the tool axis (ignoring limits).
134    Unreachable { axis: Point3 },
135    /// Every solution violates a joint's limits. `index` is the CL point.
136    LimitViolation { index: usize, joint: String },
137}
138
139impl std::fmt::Display for KinematicsError {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        match self {
142            KinematicsError::InvalidMachine(m) => write!(f, "machine: {m}"),
143            KinematicsError::InvalidInput(m) => write!(f, "kinematics: {m}"),
144            KinematicsError::InvalidAxis => write!(f, "tool axis must be a finite non-zero vector"),
145            KinematicsError::Unreachable { axis } => {
146                write!(f, "tool axis [{}, {}, {}] is unreachable on this machine", axis.x, axis.y, axis.z)
147            }
148            KinematicsError::LimitViolation { index, joint } => {
149                write!(f, "CL point {index} needs rotary {joint} outside its limits")
150            }
151        }
152    }
153}
154
155impl std::error::Error for KinematicsError {}
156
157impl From<KinematicsError> for crate::CamError {
158    fn from(e: KinematicsError) -> Self {
159        crate::CamError::InvalidInput(e.to_string())
160    }
161}
162
163// ---------------------------------------------------------------------------
164// Rotation helpers
165// ---------------------------------------------------------------------------
166
167/// Rodrigues rotation of `v` by `theta` radians about the unit direction `d`.
168pub fn rotate(d: Point3, theta: f64, v: Point3) -> Point3 {
169    let (s, c) = theta.sin_cos();
170    v.scale(c).add(d.cross(v).scale(s)).add(d.scale(d.dot(v) * (1.0 - c)))
171}
172
173/// Rotation of the point `x` about the line through `o` with direction `d`.
174pub fn rotate_about(o: Point3, d: Point3, theta: f64, x: Point3) -> Point3 {
175    o.add(rotate(d, theta, x.sub(o)))
176}
177
178/// Signed angle (radians) about `d` from `x` to `y`, both perpendicular to `d`
179/// and non-zero.
180fn signed_angle(d: Point3, x: Point3, y: Point3) -> f64 {
181    (x.cross(y).dot(d)).atan2(x.dot(y))
182}
183
184/// Component of `v` perpendicular to the unit direction `d`.
185fn perp(d: Point3, v: Point3) -> Point3 {
186    v.sub(d.scale(v.dot(d)))
187}
188
189/// Wrap radians into `(-π, π]`.
190fn wrap_pi(a: f64) -> f64 {
191    let mut r = a % (2.0 * PI);
192    if r <= -PI {
193        r += 2.0 * PI;
194    } else if r > PI {
195        r -= 2.0 * PI;
196    }
197    r
198}
199
200/// Wrap degrees into `(-180, 180]`.
201pub fn wrap_deg(a: f64) -> f64 {
202    let mut r = a % 360.0;
203    if r <= -180.0 {
204        r += 360.0;
205    } else if r > 180.0 {
206        r -= 360.0;
207    }
208    r
209}
210
211/// One branch of [`two_axis`].
212#[derive(Clone, Copy, Debug, PartialEq)]
213pub struct TwoAxisSolution {
214    /// Angle about the first axis (radians).
215    pub alpha: f64,
216    /// Angle about the second axis (radians).
217    pub beta: f64,
218    pub singular: bool,
219}
220
221/// Solve `R(d_b, β) R(d_a, α) u = w` for unit `u`, `w`, `d_a`, `d_b`.
222///
223/// `prev` supplies the held values of free angles (singular configurations).
224/// `cone` is the singular cone half-angle in radians: when `w` is within
225/// it of `±d_b`, `β` is free; when the first rotation cannot change the dot
226/// product with `d_b`, `α` is free. Returns 0, 1 or 2 branches, branch 0 first.
227pub fn two_axis(
228    d_a: Point3,
229    d_b: Point3,
230    u: Point3,
231    w: Point3,
232    prev: [f64; 2],
233    cone: f64,
234    cos_eps: f64,
235) -> Vec<TwoAxisSolution> {
236    // Rotation about d_b preserves the dot with d_b:
237    //   (R(d_a, α) u)·d_b = A cos α + B sin α + C = k.
238    let ua = u.dot(d_a);
239    let ab = d_a.dot(d_b);
240    let a = u.dot(d_b) - ua * ab;
241    let b = d_a.cross(u).dot(d_b);
242    let c = ua * ab;
243    let k = w.dot(d_b);
244    // r = sin(u, d_a) · sin(d_a, d_b): the amplitude the first joint can give
245    // the dot product. Inside the singular cone the first angle is free.
246    let r = a.hypot(b);
247    let sin_cone = cone.sin().max(0.0);
248    let mut alphas: Vec<(f64, bool)> = Vec::new();
249    if r <= sin_cone.max(cos_eps) {
250        if (k - c).abs() > r + cos_eps {
251            return Vec::new();
252        }
253        alphas.push((prev[0], true));
254    } else {
255        let phi = b.atan2(a);
256        let ratio = (k - c) / r;
257        if ratio > 1.0 + cos_eps || ratio < -1.0 - cos_eps {
258            return Vec::new();
259        }
260        let delta = ratio.clamp(-1.0, 1.0).acos();
261        alphas.push((wrap_pi(phi + delta), false));
262        if delta > 0.0 {
263            alphas.push((wrap_pi(phi - delta), false));
264        }
265    }
266    let w_perp = perp(d_b, w);
267    let w_singular = w_perp.length() <= sin_cone.max(cos_eps);
268    let mut out = Vec::new();
269    for (alpha, alpha_free) in alphas {
270        let u1 = rotate(d_a, alpha, u);
271        let u_perp = perp(d_b, u1);
272        let (beta, beta_free) = if w_singular || u_perp.length() <= 1e-12 {
273            (prev[1], true)
274        } else {
275            (wrap_pi(signed_angle(d_b, u_perp, w_perp)), false)
276        };
277        out.push(TwoAxisSolution { alpha, beta, singular: alpha_free || beta_free });
278    }
279    out
280}
281
282/// Solve `R(d, θ) u = w` for one joint: reachable only when `u·d = w·d`.
283/// `prev` holds the free angle when `u ∥ d`.
284pub fn one_axis(d: Point3, u: Point3, w: Point3, prev: f64, cone: f64, cos_eps: f64) -> Option<(f64, bool)> {
285    if (u.dot(d) - w.dot(d)).abs() > cos_eps {
286        return None;
287    }
288    let up = perp(d, u);
289    let wp = perp(d, w);
290    if wp.length() <= cone.sin().max(cos_eps) || up.length() <= cos_eps {
291        return Some((prev, true));
292    }
293    Some((wrap_pi(signed_angle(d, up, wp)), false))
294}
295
296// ---------------------------------------------------------------------------
297// Chains
298// ---------------------------------------------------------------------------
299
300fn joint(axis: &RotaryAxis, carrier: Carrier, wcs_origin: Point3) -> Result<Joint, KinematicsError> {
301    axis.validate().map_err(KinematicsError::InvalidMachine)?;
302    let direction = axis.direction.normalized().ok_or_else(|| {
303        KinematicsError::InvalidMachine(format!("rotary axis {} has a zero direction", axis.name))
304    })?;
305    let origin = match carrier {
306        Carrier::Table => axis.origin.sub(wcs_origin),
307        Carrier::Head => axis.origin,
308    };
309    Ok(Joint { name: axis.name.clone(), direction, origin, limits_deg: axis.limits_deg, carrier })
310}
311
312impl Chain {
313    /// Build the chain of `machine`. Table pivots given in machine coordinates
314    /// are shifted into S by the setup's `wcs_origin`.
315    pub fn from_machine(machine: &Machine, wcs_origin: Point3) -> Result<Chain, KinematicsError> {
316        machine.validate().map_err(KinematicsError::InvalidMachine)?;
317        if !wcs_origin.is_finite() {
318            return Err(KinematicsError::InvalidMachine("work offset origin is not finite".into()));
319        }
320        let chain = match &machine.kind {
321            MachineKind::Mill3 => Chain { kind: ChainKind::Mill3, joints: Vec::new() },
322            MachineKind::Lathe => {
323                return Err(KinematicsError::InvalidMachine("lathe kinematics are not a milling chain".into()))
324            }
325            MachineKind::Mill4 { rotary } => {
326                Chain { kind: ChainKind::Mill4, joints: vec![joint(rotary, Carrier::Table, wcs_origin)?] }
327            }
328            // A mill-turn's C axis is one table joint, as C11b specified (item 3 of its
329            // request); the optional Y is constrained in the owned millturn API.
330            MachineKind::MillTurn { c_axis, .. } => {
331                Chain { kind: ChainKind::Mill4, joints: vec![joint(c_axis, Carrier::Table, wcs_origin)?] }
332            }
333            MachineKind::Mill5 { kinematics } => match kinematics {
334                Kinematics::TableTable { primary, secondary } => Chain {
335                    kind: ChainKind::TableTable,
336                    joints: vec![
337                        joint(primary, Carrier::Table, wcs_origin)?,
338                        joint(secondary, Carrier::Table, wcs_origin)?,
339                    ],
340                },
341                Kinematics::HeadHead { primary, secondary } => Chain {
342                    kind: ChainKind::HeadHead,
343                    joints: vec![
344                        joint(primary, Carrier::Head, wcs_origin)?,
345                        joint(secondary, Carrier::Head, wcs_origin)?,
346                    ],
347                },
348                Kinematics::HeadTable { head, table } => Chain {
349                    kind: ChainKind::HeadTable,
350                    joints: vec![joint(head, Carrier::Head, wcs_origin)?, joint(table, Carrier::Table, wcs_origin)?],
351                },
352            },
353        };
354        if chain.joints.len() == 2 {
355            let (a, b) = (chain.joints[0].direction, chain.joints[1].direction);
356            if a.cross(b).length() <= 1e-9 {
357                return Err(KinematicsError::InvalidMachine("the two rotary axes are parallel".into()));
358            }
359            if chain.joints[0].name == chain.joints[1].name {
360                return Err(KinematicsError::InvalidMachine("the two rotary axes share a name".into()));
361            }
362        }
363        Ok(chain)
364    }
365
366    /// A chain without rotary joints.
367    pub fn mill3() -> Chain {
368        Chain { kind: ChainKind::Mill3, joints: Vec::new() }
369    }
370
371    pub fn rotary_count(&self) -> usize {
372        self.joints.len()
373    }
374
375    /// Joint names in output order.
376    pub fn joint_names(&self) -> Vec<&str> {
377        self.joints.iter().map(|j| j.name.as_str()).collect()
378    }
379
380    fn check_angles(&self, angles_deg: &[f64]) -> Result<(), KinematicsError> {
381        if angles_deg.len() != self.joints.len() {
382            return Err(KinematicsError::InvalidInput(format!(
383                "expected {} rotary angles, got {}",
384                self.joints.len(),
385                angles_deg.len()
386            )));
387        }
388        if angles_deg.iter().any(|a| !a.is_finite()) {
389            return Err(KinematicsError::InvalidInput("rotary angle is not finite".into()));
390        }
391        Ok(())
392    }
393
394    /// Rigid transform of a rotary joint at `theta` radians: `x ↦ o + R(x − o)`.
395    fn joint_transform(j: &Joint, theta: f64) -> RigidTransform {
396        let r = RigidTransform::axis_angle(j.direction, theta, Point3::ZERO).unwrap_or_else(|_| RigidTransform::identity());
397        let shift = j.origin.sub(r.apply_vector(j.origin));
398        RigidTransform { rotation: r.rotation, translation: shift }
399    }
400
401    /// World placement of a link's frame at the given angles and linear
402    /// position (`x_world = T(x_link)`). Table links are given in S at zero
403    /// angles; head and tool links relative to the controlled point.
404    pub fn link_transform(&self, link: Link, angles_deg: &[f64], linear: Point3) -> Result<RigidTransform, KinematicsError> {
405        self.check_angles(angles_deg)?;
406        let th: Vec<f64> = angles_deg.iter().map(|a| a.to_radians()).collect();
407        let j = &self.joints;
408        let table_joints: Vec<usize> = (0..j.len()).filter(|&k| j[k].carrier == Carrier::Table).collect();
409        let head_joints: Vec<usize> = (0..j.len()).filter(|&k| j[k].carrier == Carrier::Head).collect();
410        // Outer-to-inner composition: for joints [a, b] carried in order, x_world = T_a(T_b(x)).
411        let compose = |ids: &[usize]| -> RigidTransform {
412            let mut t = RigidTransform::identity();
413            for &k in ids.iter().rev() {
414                // t = T_k ∘ t  (apply t first, then T_k)
415                t = t.then(&Self::joint_transform(&j[k], th[k]));
416            }
417            t
418        };
419        let shift = RigidTransform::translation(linear);
420        Ok(match link {
421            Link::Base => RigidTransform::identity(),
422            Link::Part => compose(&table_joints),
423            Link::Tool => compose(&head_joints).then(&shift),
424            Link::Joint { index: k } => {
425                if k >= j.len() {
426                    return Err(KinematicsError::InvalidInput(format!("link joint {k} does not exist")));
427                }
428                match j[k].carrier {
429                    Carrier::Table => {
430                        let upto: Vec<usize> = table_joints.iter().copied().take_while(|&i| i <= k).collect();
431                        compose(&upto)
432                    }
433                    Carrier::Head => {
434                        let upto: Vec<usize> = head_joints.iter().copied().take_while(|&i| i <= k).collect();
435                        compose(&upto).then(&shift)
436                    }
437                }
438            }
439        })
440    }
441
442    /// Part point `x` (in S) to the machine frame W at the given angles.
443    pub fn part_to_world(&self, angles_deg: &[f64], x: Point3) -> Point3 {
444        let j = &self.joints;
445        match self.kind {
446            ChainKind::Mill3 | ChainKind::HeadHead => x,
447            ChainKind::Mill4 => rotate_about(j[0].origin, j[0].direction, angles_deg[0].to_radians(), x),
448            ChainKind::TableTable => {
449                let inner = rotate_about(j[1].origin, j[1].direction, angles_deg[1].to_radians(), x);
450                rotate_about(j[0].origin, j[0].direction, angles_deg[0].to_radians(), inner)
451            }
452            ChainKind::HeadTable => rotate_about(j[1].origin, j[1].direction, angles_deg[1].to_radians(), x),
453        }
454    }
455
456    /// Machine-frame point to S at the given angles (inverse of [`Chain::part_to_world`]).
457    pub fn world_to_part(&self, angles_deg: &[f64], x: Point3) -> Point3 {
458        let j = &self.joints;
459        match self.kind {
460            ChainKind::Mill3 | ChainKind::HeadHead => x,
461            ChainKind::Mill4 => rotate_about(j[0].origin, j[0].direction, -angles_deg[0].to_radians(), x),
462            ChainKind::TableTable => {
463                let outer = rotate_about(j[0].origin, j[0].direction, -angles_deg[0].to_radians(), x);
464                rotate_about(j[1].origin, j[1].direction, -angles_deg[1].to_radians(), outer)
465            }
466            ChainKind::HeadTable => rotate_about(j[1].origin, j[1].direction, -angles_deg[1].to_radians(), x),
467        }
468    }
469
470    /// Part-frame direction to W (rotation only).
471    pub fn part_dir_to_world(&self, angles_deg: &[f64], v: Point3) -> Point3 {
472        let j = &self.joints;
473        match self.kind {
474            ChainKind::Mill3 | ChainKind::HeadHead => v,
475            ChainKind::Mill4 => rotate(j[0].direction, angles_deg[0].to_radians(), v),
476            ChainKind::TableTable => {
477                let inner = rotate(j[1].direction, angles_deg[1].to_radians(), v);
478                rotate(j[0].direction, angles_deg[0].to_radians(), inner)
479            }
480            ChainKind::HeadTable => rotate(j[1].direction, angles_deg[1].to_radians(), v),
481        }
482    }
483
484    /// Tool axis in W at the given angles.
485    pub fn tool_axis_world(&self, angles_deg: &[f64]) -> Point3 {
486        let j = &self.joints;
487        match self.kind {
488            ChainKind::Mill3 | ChainKind::Mill4 | ChainKind::TableTable => Point3::Z,
489            ChainKind::HeadHead => {
490                let inner = rotate(j[1].direction, angles_deg[1].to_radians(), Point3::Z);
491                rotate(j[0].direction, angles_deg[0].to_radians(), inner)
492            }
493            ChainKind::HeadTable => rotate(j[0].direction, angles_deg[0].to_radians(), Point3::Z),
494        }
495    }
496
497    /// Offset from the controlled point (linear axes) to the tool tip in W,
498    /// produced by the head joints: zero for table-only chains.
499    pub fn head_offset(&self, angles_deg: &[f64]) -> Point3 {
500        let j = &self.joints;
501        match self.kind {
502            ChainKind::Mill3 | ChainKind::Mill4 | ChainKind::TableTable => Point3::ZERO,
503            ChainKind::HeadHead => {
504                let (t1, t2) = (angles_deg[0].to_radians(), angles_deg[1].to_radians());
505                let o1 = j[0].origin;
506                let o2 = j[1].origin;
507                let inner = o2.sub(rotate(j[1].direction, t2, o2)).sub(o1);
508                o1.add(rotate(j[0].direction, t1, inner))
509            }
510            ChainKind::HeadTable => {
511                let o = j[0].origin;
512                o.sub(rotate(j[0].direction, angles_deg[0].to_radians(), o))
513            }
514        }
515    }
516
517    /// Forward kinematics: the tip and unit tool axis in S of a pose.
518    pub fn forward(&self, pose: &Pose, options: &KinematicsOptions) -> Result<(Point3, Point3), KinematicsError> {
519        self.check_angles(&pose.angles_deg)?;
520        if !pose.linear.is_finite() {
521            return Err(KinematicsError::InvalidInput("linear position is not finite".into()));
522        }
523        let axis_w = self.tool_axis_world(&pose.angles_deg);
524        let axis = self.world_dir_to_part(&pose.angles_deg, axis_w);
525        let tip = if options.tcp {
526            pose.linear
527        } else {
528            let tip_w = pose.linear.add(self.head_offset(&pose.angles_deg));
529            self.world_to_part(&pose.angles_deg, tip_w)
530        };
531        Ok((tip, axis))
532    }
533
534    /// W direction to the part frame (rotation only).
535    pub fn world_dir_to_part(&self, angles_deg: &[f64], v: Point3) -> Point3 {
536        let j = &self.joints;
537        match self.kind {
538            ChainKind::Mill3 | ChainKind::HeadHead => v,
539            ChainKind::Mill4 => rotate(j[0].direction, -angles_deg[0].to_radians(), v),
540            ChainKind::TableTable => {
541                let outer = rotate(j[0].direction, -angles_deg[0].to_radians(), v);
542                rotate(j[1].direction, -angles_deg[1].to_radians(), outer)
543            }
544            ChainKind::HeadTable => rotate(j[1].direction, -angles_deg[1].to_radians(), v),
545        }
546    }
547
548    /// Linear axis words for a tip in S at the given angles.
549    pub fn linear_for(&self, tip: Point3, angles_deg: &[f64], options: &KinematicsOptions) -> Point3 {
550        if options.tcp {
551            tip
552        } else {
553            self.part_to_world(angles_deg, tip).sub(self.head_offset(angles_deg))
554        }
555    }
556
557    /// Inverse kinematics: every joint configuration (ignoring limits, angles
558    /// in `(-180, 180]`) that puts the tool along `axis` at `tip`.
559    /// `previous` holds free angles in singular configurations (zero when
560    /// `None`). Returns an empty vector only for a 3-axis chain whose tool
561    /// axis is not `+Z` (reported as `Unreachable`).
562    pub fn inverse(
563        &self,
564        tip: Point3,
565        axis: Option<Point3>,
566        previous: Option<&[f64]>,
567        options: &KinematicsOptions,
568    ) -> Result<Vec<Pose>, KinematicsError> {
569        options.validate()?;
570        if !tip.is_finite() {
571            return Err(KinematicsError::InvalidInput("tool tip is not finite".into()));
572        }
573        let a = match axis {
574            None => Point3::Z,
575            Some(v) => v.normalized().ok_or(KinematicsError::InvalidAxis)?,
576        };
577        let prev: Vec<f64> = match previous {
578            Some(p) => {
579                self.check_angles(p)?;
580                p.iter().map(|d| d.to_radians()).collect()
581            }
582            None => vec![0.0; self.joints.len()],
583        };
584        let cone = options.singular_cone_deg.to_radians();
585        let eps = options.cos_eps;
586        let unreachable = || KinematicsError::Unreachable { axis: a };
587        let j = &self.joints;
588        let mut raw: Vec<(Vec<f64>, bool)> = Vec::new();
589        match self.kind {
590            ChainKind::Mill3 => {
591                if 1.0 - a.dot(Point3::Z) > eps {
592                    return Err(unreachable());
593                }
594                raw.push((Vec::new(), false));
595            }
596            ChainKind::Mill4 => {
597                let (theta, singular) =
598                    one_axis(j[0].direction, a, Point3::Z, prev[0], cone, eps).ok_or_else(unreachable)?;
599                raw.push((vec![theta], singular));
600            }
601            ChainKind::TableTable => {
602                // R_P(θ1) R_Sec(θ2) a = Z: first the secondary, then the primary.
603                for s in two_axis(j[1].direction, j[0].direction, a, Point3::Z, [prev[1], prev[0]], cone, eps) {
604                    raw.push((vec![s.beta, s.alpha], s.singular));
605                }
606            }
607            ChainKind::HeadHead => {
608                // R_P(θ1) R_Sec(θ2) Z = a.
609                for s in two_axis(j[1].direction, j[0].direction, Point3::Z, a, [prev[1], prev[0]], cone, eps) {
610                    raw.push((vec![s.beta, s.alpha], s.singular));
611                }
612            }
613            ChainKind::HeadTable => {
614                // R_H(θh) Z = R_T(θt) a  ⇔  R_T(−θt) R_H(θh) Z = a.
615                for s in two_axis(j[0].direction, j[1].direction, Point3::Z, a, [prev[0], -prev[1]], cone, eps) {
616                    raw.push((vec![s.alpha, -s.beta], s.singular));
617                }
618            }
619        }
620        if raw.is_empty() {
621            return Err(unreachable());
622        }
623        let mut poses = Vec::with_capacity(raw.len());
624        for (branch, (angles, singular)) in raw.into_iter().enumerate() {
625            let angles_deg: Vec<f64> = angles.iter().map(|r| wrap_deg(r.to_degrees())).collect();
626            let linear = self.linear_for(tip, &angles_deg, options);
627            poses.push(Pose { linear, angles_deg, branch: branch as u8, singular });
628        }
629        Ok(poses)
630    }
631}
632