Skip to main content

mmd_anim_runtime/
ik_primitive.rs

1use glam::{Mat4, Quat, Vec3A};
2
3use crate::IkAngleLimit;
4
5#[derive(Clone, Debug, PartialEq)]
6pub struct IkChainLinkDefinition {
7    pub bone_slot: usize,
8    pub angle_limit: Option<IkAngleLimit>,
9}
10
11#[derive(Clone, Debug, PartialEq)]
12pub struct IkChainDefinition {
13    pub parent_slots: Vec<Option<usize>>,
14    pub rest_positions: Vec<Vec3A>,
15    /// Per-bone fixed axis used to constrain the CCD rotation axis during IK
16    /// link steps. Does not project ordinary pose rotations.
17    pub fixed_axes: Vec<Option<Vec3A>>,
18    pub target_slot: usize,
19    pub links: Vec<IkChainLinkDefinition>,
20    pub iteration_count: u32,
21    pub limit_angle: f32,
22}
23
24#[derive(Clone, Copy, Debug, PartialEq)]
25pub struct IkChainPoseInput<'a> {
26    pub parent_world_matrix: Option<Mat4>,
27    pub local_position_offsets: &'a [Vec3A],
28    pub local_rotations: &'a [Quat],
29    pub goal_position: Vec3A,
30    pub tolerance: f32,
31    pub max_iterations_cap: Option<u32>,
32}
33
34#[derive(Clone, Debug, PartialEq)]
35pub struct IkChainSolveOutput {
36    pub solved_link_rotations: Vec<Quat>,
37    pub final_distance: f32,
38    pub executed_iterations: u32,
39    pub link_steps: u32,
40}
41
42#[derive(Debug)]
43pub struct IkChainSolver {
44    definition: IkChainDefinition,
45    /// Per-bone local-axis basis for angle-limit evaluation. Stored privately so
46    /// existing `IkChainDefinition` struct literals stay source-compatible.
47    local_axis_bases: Vec<Option<Quat>>,
48    world_matrices: Vec<Mat4>,
49    local_rotations: Vec<Quat>,
50    base_rotations: Vec<Quat>,
51    ik_rotations: Vec<Quat>,
52    best_ik_rotations: Vec<Quat>,
53    chain_states: Vec<ChainLinkState>,
54}
55
56impl IkChainSolver {
57    /// Create a solver with no local-axis bases (unit XYZ angle-limit frames).
58    pub fn new(definition: IkChainDefinition) -> Self {
59        let bone_count = definition.rest_positions.len();
60        Self::new_with_local_axis_bases(definition, vec![None; bone_count])
61    }
62
63    /// Additive constructor: attach per-bone local-axis bases used only as the
64    /// angle-limit evaluation frame. Shorter lists are padded with `None`;
65    /// longer lists are truncated to the definition bone count.
66    pub fn new_with_local_axis_bases(
67        definition: IkChainDefinition,
68        local_axis_bases: Vec<Option<Quat>>,
69    ) -> Self {
70        let bone_count = definition.rest_positions.len();
71        let link_count = definition.links.len();
72        let mut bases = local_axis_bases
73            .into_iter()
74            .map(|basis| {
75                basis.filter(|basis| basis.is_finite() && basis.length_squared() > f32::EPSILON)
76            })
77            .collect::<Vec<_>>();
78        bases.resize(bone_count, None);
79        bases.truncate(bone_count);
80        Self {
81            definition,
82            local_axis_bases: bases,
83            world_matrices: vec![Mat4::IDENTITY; bone_count],
84            local_rotations: vec![Quat::IDENTITY; bone_count],
85            base_rotations: Vec::with_capacity(link_count),
86            ik_rotations: Vec::with_capacity(link_count),
87            best_ik_rotations: Vec::with_capacity(link_count),
88            chain_states: Vec::with_capacity(link_count),
89        }
90    }
91
92    pub fn solve(&mut self, input: IkChainPoseInput<'_>) -> IkChainSolveOutput {
93        let tolerance = input.tolerance.max(0.0);
94        let iteration_count = input
95            .max_iterations_cap
96            .map(|cap| self.definition.iteration_count.min(cap))
97            .unwrap_or(self.definition.iteration_count)
98            .max(1) as usize;
99        let limit_angle = self.definition.limit_angle.max(0.0);
100        let link_count = self.definition.links.len();
101
102        self.local_rotations.copy_from_slice(input.local_rotations);
103        self.update_world_matrices(input);
104
105        self.base_rotations.clear();
106        self.base_rotations.extend(
107            self.definition
108                .links
109                .iter()
110                .map(|link| self.local_rotations[link.bone_slot]),
111        );
112        self.ik_rotations.clear();
113        self.ik_rotations.resize(link_count, Quat::IDENTITY);
114        self.best_ik_rotations.clear();
115        self.best_ik_rotations.resize(link_count, Quat::IDENTITY);
116        self.chain_states.clear();
117        self.chain_states
118            .resize_with(link_count, ChainLinkState::default);
119
120        self.apply_link_rotations();
121        self.update_world_matrices(input);
122
123        let mut final_distance = f32::MAX;
124        let mut best_distance = f32::MAX;
125        let mut executed_iterations = 0u32;
126        let mut link_steps = 0u32;
127
128        for iteration in 0..iteration_count {
129            let eff_pos = translation(self.world_matrices[self.definition.target_slot]);
130            final_distance = (eff_pos - input.goal_position).length();
131            if final_distance <= tolerance {
132                break;
133            }
134            executed_iterations += 1;
135
136            for link_index in 0..link_count {
137                let link = &self.definition.links[link_index];
138                let link_slot = link.bone_slot;
139
140                if link_slot == self.definition.target_slot {
141                    continue;
142                }
143
144                let link_world = self.world_matrices[link_slot];
145                let link_pos = translation(link_world);
146                let eff_pos = translation(self.world_matrices[self.definition.target_slot]);
147                let link_world_rot = rotation(link_world);
148                let local_effector = link_world_rot.inverse().mul_vec3a(eff_pos - link_pos);
149                let local_target = link_world_rot
150                    .inverse()
151                    .mul_vec3a(input.goal_position - link_pos);
152
153                if local_effector.length_squared() <= f32::EPSILON
154                    || local_target.length_squared() <= f32::EPSILON
155                {
156                    continue;
157                }
158
159                let bone_slot = link.bone_slot;
160                let fixed_axis = self.definition.fixed_axes.get(bone_slot).copied().flatten();
161                let local_axis_basis = self.local_axis_bases.get(bone_slot).copied().flatten();
162                solve_link_step(LinkStepInput {
163                    local_effector: &local_effector,
164                    local_target: &local_target,
165                    link_index,
166                    base_rotations: &self.base_rotations,
167                    ik_rotations: &mut self.ik_rotations,
168                    chain_states: &mut self.chain_states,
169                    angle_limit: link.angle_limit,
170                    iteration,
171                    limit_angle,
172                    local_axis_basis,
173                    fixed_axis,
174                });
175
176                self.apply_link_rotations();
177                self.update_world_matrices(input);
178                link_steps += 1;
179            }
180
181            let current_distance = {
182                let eff = translation(self.world_matrices[self.definition.target_slot]);
183                (eff - input.goal_position).length()
184            };
185            final_distance = current_distance;
186            if current_distance < best_distance {
187                best_distance = current_distance;
188                self.best_ik_rotations.copy_from_slice(&self.ik_rotations);
189                if current_distance <= tolerance {
190                    break;
191                }
192            } else {
193                self.ik_rotations.copy_from_slice(&self.best_ik_rotations);
194                self.apply_link_rotations();
195                self.update_world_matrices(input);
196                break;
197            }
198        }
199
200        self.ik_rotations.copy_from_slice(&self.best_ik_rotations);
201        self.apply_link_rotations();
202        self.update_world_matrices(input);
203
204        let solved_link_rotations = self
205            .definition
206            .links
207            .iter()
208            .map(|link| self.local_rotations[link.bone_slot])
209            .collect();
210
211        IkChainSolveOutput {
212            solved_link_rotations,
213            final_distance,
214            executed_iterations,
215            link_steps,
216        }
217    }
218
219    pub(crate) fn update_world_matrices(&mut self, input: IkChainPoseInput<'_>) {
220        update_mini_chain_world_matrices(
221            &self.definition,
222            input.parent_world_matrix.unwrap_or(Mat4::IDENTITY),
223            input.local_position_offsets,
224            &self.local_rotations,
225            &mut self.world_matrices,
226        );
227    }
228
229    fn apply_link_rotations(&mut self) {
230        for (i, link) in self.definition.links.iter().enumerate() {
231            let effective = (self.ik_rotations[i] * self.base_rotations[i]).normalize();
232            // Fixed-axis is applied only during the CCD link step, not as a
233            // post-projection of ordinary / base pose rotations.
234            self.local_rotations[link.bone_slot] = effective;
235        }
236    }
237}
238
239pub(crate) fn update_mini_chain_world_matrices(
240    definition: &IkChainDefinition,
241    parent_world_matrix: Mat4,
242    local_position_offsets: &[Vec3A],
243    local_rotations: &[Quat],
244    world_matrices: &mut [Mat4],
245) {
246    for slot in 0..definition.rest_positions.len() {
247        let local_position = definition.rest_positions[slot] + local_position_offsets[slot];
248        let local_rotation = local_rotations[slot];
249        let local_matrix = Mat4::from_scale_rotation_translation(
250            Vec3A::ONE.into(),
251            local_rotation,
252            local_position.into(),
253        );
254        world_matrices[slot] = match definition.parent_slots[slot] {
255            Some(parent) => world_matrices[parent] * local_matrix,
256            None => parent_world_matrix * local_matrix,
257        };
258    }
259}
260
261pub(crate) fn solve_link_step(input: LinkStepInput<'_>) {
262    // fixedAxis is a hard CCD constraint. When present it owns the free
263    // rotation axis; angle limits (single- or multi-axis) are then applied as a
264    // post-step clamp so both constraints compose on every solver path.
265    if let Some(fixed_axis) = input.fixed_axis {
266        let prior_ik_rotation = input.ik_rotations[input.link_index];
267        let prior_chain_state = input.chain_states[input.link_index];
268        solve_unconstrained_link_step(UnconstrainedLinkStepInput {
269            local_effector: input.local_effector,
270            local_target: input.local_target,
271            link_index: input.link_index,
272            base_rotations: input.base_rotations,
273            ik_rotations: input.ik_rotations,
274            limit_angle: input.limit_angle,
275            fixed_axis: Some(fixed_axis),
276        });
277        if let Some(angle_limit) = input.angle_limit {
278            // Preserve a valid fixed-axis candidate as-is. Decomposing and
279            // rebuilding an already-valid rotation can select a different
280            // Euler representation, especially with a non-identity base pose.
281            if link_rotation_within_angle_limits(
282                input.link_index,
283                input.base_rotations,
284                input.ik_rotations,
285                angle_limit,
286                input.local_axis_basis,
287            ) {
288                return;
289            }
290            clamp_link_rotation_to_angle_limits(ClampAngleLimitInput {
291                link_index: input.link_index,
292                base_rotations: input.base_rotations,
293                ik_rotations: input.ik_rotations,
294                chain_states: input.chain_states,
295                limits: angle_limit,
296                local_axis_basis: input.local_axis_basis,
297            });
298            // Euler clamping can reintroduce non-twist components (and is
299            // singular near ±π/2); re-project so fixedAxis remains hard.
300            project_link_rotation_onto_fixed_axis(
301                input.link_index,
302                input.base_rotations,
303                input.ik_rotations,
304                fixed_axis,
305            );
306            // A twist about an arbitrary fixed axis may leave the Euler box
307            // after projection. Keep the previously accepted IK step rather
308            // than emitting a rotation that violates the PMX link limits.
309            if !link_rotation_within_angle_limits(
310                input.link_index,
311                input.base_rotations,
312                input.ik_rotations,
313                angle_limit,
314                input.local_axis_basis,
315            ) {
316                input.ik_rotations[input.link_index] = prior_ik_rotation;
317                input.chain_states[input.link_index] = prior_chain_state;
318            }
319        }
320        return;
321    }
322
323    let single_axis = get_single_axis_limit(input.angle_limit);
324    if let (Some(angle_limit), Some(axis_index)) = (input.angle_limit, single_axis) {
325        solve_plane_link_step(PlaneLinkStepInput {
326            local_effector: input.local_effector,
327            local_target: input.local_target,
328            link_index: input.link_index,
329            base_rotations: input.base_rotations,
330            ik_rotations: input.ik_rotations,
331            chain_states: input.chain_states,
332            axis_index,
333            limits: angle_limit,
334            iteration: input.iteration,
335            limit_angle: input.limit_angle,
336            local_axis_basis: input.local_axis_basis,
337        });
338    } else if let Some(angle_limit) = input.angle_limit {
339        solve_limited_axes_link_step(LimitedAxesLinkStepInput {
340            local_effector: input.local_effector,
341            local_target: input.local_target,
342            link_index: input.link_index,
343            base_rotations: input.base_rotations,
344            ik_rotations: input.ik_rotations,
345            chain_states: input.chain_states,
346            limits: angle_limit,
347            limit_angle: input.limit_angle,
348            local_axis_basis: input.local_axis_basis,
349        });
350    } else {
351        solve_unconstrained_link_step(UnconstrainedLinkStepInput {
352            local_effector: input.local_effector,
353            local_target: input.local_target,
354            link_index: input.link_index,
355            base_rotations: input.base_rotations,
356            ik_rotations: input.ik_rotations,
357            limit_angle: input.limit_angle,
358            fixed_axis: input.fixed_axis,
359        });
360    }
361}
362
363fn link_rotation_within_angle_limits(
364    link_index: usize,
365    base_rotations: &[Quat],
366    ik_rotations: &[Quat],
367    limits: IkAngleLimit,
368    local_axis_basis: Option<Quat>,
369) -> bool {
370    let chain = ik_rotations[link_index] * base_rotations[link_index];
371    if !chain.is_finite() || chain.length_squared() <= f32::EPSILON {
372        return false;
373    }
374    let basis =
375        local_axis_basis.filter(|basis| basis.is_finite() && basis.length_squared() > f32::EPSILON);
376    let (q_b, q_b_inv) = basis.map_or((Quat::IDENTITY, Quat::IDENTITY), |basis| {
377        let basis = basis.normalize();
378        (basis, basis.inverse())
379    });
380    let local = (q_b_inv * chain.normalize() * q_b).normalize();
381    if !local.is_finite() {
382        return false;
383    }
384    let euler = decompose_euler_xyz(&quat_to_rotation_mat3(local), &[0.0; 3]);
385    euler.iter().enumerate().all(|(axis, value)| {
386        let (lower, upper) = limit_axis_bounds(limits, axis);
387        *value >= lower - 1.0e-5 && *value <= upper + 1.0e-5
388    })
389}
390
391struct ClampAngleLimitInput<'a> {
392    link_index: usize,
393    base_rotations: &'a [Quat],
394    ik_rotations: &'a mut [Quat],
395    chain_states: &'a mut [ChainLinkState],
396    limits: IkAngleLimit,
397    local_axis_basis: Option<Quat>,
398}
399
400/// Clamp the current chain rotation into angle limits without adding free CCD
401/// motion. Used after a fixed-axis step so both constraints compose.
402fn clamp_link_rotation_to_angle_limits(input: ClampAngleLimitInput<'_>) {
403    let base = input.base_rotations[input.link_index];
404    let current = input.ik_rotations[input.link_index] * base;
405    if !current.is_finite() || current.length_squared() <= f32::EPSILON {
406        return;
407    }
408    let current = current.normalize();
409    let (q_b, q_b_inv) = match input.local_axis_basis {
410        Some(basis) if basis.is_finite() => (basis.normalize(), basis.normalize().inverse()),
411        _ => (Quat::IDENTITY, Quat::IDENTITY),
412    };
413    let current_la = q_b_inv * current * q_b;
414    if !current_la.is_finite() || current_la.length_squared() <= f32::EPSILON {
415        return;
416    }
417    let current_la = current_la.normalize();
418    let current_mat = quat_to_rotation_mat3(current_la);
419    let state = &mut input.chain_states[input.link_index];
420    let mut euler = decompose_euler_xyz(&current_mat, &state.previous_euler);
421    if !euler.iter().all(|v| v.is_finite()) {
422        return;
423    }
424    for (axis_index, value) in euler.iter_mut().enumerate() {
425        let (lower, upper) = limit_axis_bounds(input.limits, axis_index);
426        *value = value.clamp(lower, upper);
427    }
428    state.previous_euler = euler;
429    if let Some(axis_index) = get_single_axis_limit(Some(input.limits)) {
430        state.plane_mode_angle = euler[axis_index];
431    }
432    let chain_rotation_la = euler_xyz_to_quat(&euler);
433    if !chain_rotation_la.is_finite() || chain_rotation_la.length_squared() <= f32::EPSILON {
434        return;
435    }
436    let chain_rotation = q_b * chain_rotation_la.normalize() * q_b_inv;
437    if !chain_rotation.is_finite() || chain_rotation.length_squared() <= f32::EPSILON {
438        return;
439    }
440    let chain_rotation = chain_rotation.normalize();
441    let next_ik = chain_rotation * base.inverse();
442    if !next_ik.is_finite() || next_ik.length_squared() <= f32::EPSILON {
443        return;
444    }
445    input.ik_rotations[input.link_index] = next_ik.normalize();
446}
447
448fn project_link_rotation_onto_fixed_axis(
449    link_index: usize,
450    base_rotations: &[Quat],
451    ik_rotations: &mut [Quat],
452    fixed_axis: Vec3A,
453) {
454    if fixed_axis.length_squared() <= f32::EPSILON || !fixed_axis.is_finite() {
455        return;
456    }
457    let ik_rotation = ik_rotations[link_index];
458    if !ik_rotation.is_finite() || ik_rotation.length_squared() <= f32::EPSILON {
459        return;
460    }
461    let base = base_rotations[link_index];
462    if !base.is_finite() || base.length_squared() <= f32::EPSILON {
463        return;
464    }
465    // The effective link rotation is `ik * base`. A fixed-axis delta is
466    // composed on the right of `base`, so its equivalent axis in the left-side
467    // IK correction is the bone-local axis rotated by the current base pose.
468    let correction_axis = base.normalize().mul_vec3a(fixed_axis.normalize());
469    let constrained = constrain_rotation_to_axis(ik_rotation.normalize(), correction_axis);
470    if !constrained.is_finite() || constrained.length_squared() <= f32::EPSILON {
471        return;
472    }
473    ik_rotations[link_index] = constrained.normalize();
474}
475
476pub(crate) struct LinkStepInput<'a> {
477    pub local_effector: &'a Vec3A,
478    pub local_target: &'a Vec3A,
479    pub link_index: usize,
480    pub base_rotations: &'a [Quat],
481    pub ik_rotations: &'a mut [Quat],
482    pub chain_states: &'a mut [ChainLinkState],
483    pub angle_limit: Option<IkAngleLimit>,
484    pub iteration: usize,
485    pub limit_angle: f32,
486    /// Optional PMX local-axis basis for angle-limit evaluation.
487    pub local_axis_basis: Option<Quat>,
488    /// Optional fixed axis that constrains the unconstrained CCD rotation axis.
489    pub fixed_axis: Option<Vec3A>,
490}
491
492struct UnconstrainedLinkStepInput<'a> {
493    local_effector: &'a Vec3A,
494    local_target: &'a Vec3A,
495    link_index: usize,
496    base_rotations: &'a [Quat],
497    ik_rotations: &'a mut [Quat],
498    limit_angle: f32,
499    fixed_axis: Option<Vec3A>,
500}
501
502fn solve_unconstrained_link_step(input: UnconstrainedLinkStepInput<'_>) {
503    let local_eff_n = input.local_effector.normalize();
504    let local_tgt_n = input.local_target.normalize();
505
506    let tiny_angle = 1e-3 * std::f32::consts::PI / 180.0;
507
508    let (axis_vec, mut angle) = if let Some(fixed) = input.fixed_axis {
509        let axis = if fixed.length_squared() > f32::EPSILON && fixed.is_finite() {
510            fixed.normalize()
511        } else {
512            return;
513        };
514        let signed = signed_projected_angle(local_eff_n, local_tgt_n, axis);
515        if signed.abs() < tiny_angle {
516            return;
517        }
518        (axis, signed)
519    } else {
520        let dot = local_eff_n.dot(local_tgt_n).clamp(-1.0, 1.0);
521        let angle = dot.acos();
522        if angle < tiny_angle {
523            return;
524        }
525        let axis = local_eff_n.cross(local_tgt_n);
526        let axis_vec = if axis.length() < 1e-5 {
527            if dot > -1.0 + 1e-5 {
528                return;
529            }
530            let basis = if local_eff_n.x.abs() < 0.9 {
531                Vec3A::new(1.0, 0.0, 0.0)
532            } else {
533                Vec3A::new(0.0, 1.0, 0.0)
534            };
535            local_eff_n.cross(basis).normalize()
536        } else {
537            axis.normalize()
538        };
539        (axis_vec, angle)
540    };
541
542    if input.limit_angle > 0.0 {
543        angle = angle.clamp(-input.limit_angle, input.limit_angle);
544    }
545
546    let delta = Quat::from_axis_angle(axis_vec.into(), angle);
547    let base = input.base_rotations[input.link_index];
548    let ik = input.ik_rotations[input.link_index];
549    let chain_rotation = (ik * base * delta).normalize();
550
551    input.ik_rotations[input.link_index] = (chain_rotation * base.inverse()).normalize();
552}
553
554pub(crate) fn translation(matrix: Mat4) -> Vec3A {
555    Vec3A::from_vec4(matrix.w_axis)
556}
557
558pub(crate) fn rotation(matrix: Mat4) -> Quat {
559    matrix.to_scale_rotation_translation().1
560}
561
562pub(crate) fn constrain_rotation_to_axis(rotation: Quat, axis: Vec3A) -> Quat {
563    let axis = axis.normalize();
564    let vector = Vec3A::new(rotation.x, rotation.y, rotation.z);
565    let projected = axis * vector.dot(axis);
566    let twist = Quat::from_xyzw(projected.x, projected.y, projected.z, rotation.w);
567    if twist.length_squared() <= f32::EPSILON {
568        Quat::IDENTITY
569    } else {
570        twist.normalize()
571    }
572}
573
574#[derive(Clone, Copy, Debug, Default, PartialEq)]
575pub(crate) struct ChainLinkState {
576    pub previous_euler: [f32; 3],
577    pub plane_mode_angle: f32,
578}
579
580pub(crate) fn get_single_axis_limit(limit: Option<IkAngleLimit>) -> Option<usize> {
581    let limit = limit?;
582    let has = [
583        limit.min.x != 0.0 || limit.max.x != 0.0,
584        limit.min.y != 0.0 || limit.max.y != 0.0,
585        limit.min.z != 0.0 || limit.max.z != 0.0,
586    ];
587    if has[0]
588        && limit.min.y == 0.0
589        && limit.max.y == 0.0
590        && limit.min.z == 0.0
591        && limit.max.z == 0.0
592    {
593        return Some(0);
594    }
595    if has[1]
596        && limit.min.x == 0.0
597        && limit.max.x == 0.0
598        && limit.min.z == 0.0
599        && limit.max.z == 0.0
600    {
601        return Some(1);
602    }
603    if has[2]
604        && limit.min.x == 0.0
605        && limit.max.x == 0.0
606        && limit.min.y == 0.0
607        && limit.max.y == 0.0
608    {
609        return Some(2);
610    }
611    None
612}
613
614pub(crate) fn quat_to_rotation_mat3(rotation: Quat) -> [f32; 9] {
615    let [x, y, z, w] = rotation.normalize().to_array();
616    let x2 = x + x;
617    let y2 = y + y;
618    let z2 = z + z;
619    let xx = x * x2;
620    let xy = x * y2;
621    let xz = x * z2;
622    let yy = y * y2;
623    let yz = y * z2;
624    let zz = z * z2;
625    let wx = w * x2;
626    let wy = w * y2;
627    let wz = w * z2;
628    [
629        1.0 - (yy + zz),
630        xy + wz,
631        xz - wy,
632        xy - wz,
633        1.0 - (xx + zz),
634        yz + wx,
635        xz + wy,
636        yz - wx,
637        1.0 - (xx + yy),
638    ]
639}
640
641pub(crate) fn decompose_euler_xyz(mat: &[f32; 9], before: &[f32; 3]) -> [f32; 3] {
642    let sy = -mat[2];
643    let mut result: [f32; 3];
644    if 1.0 - sy.abs() < 1e-6 {
645        let y = sy.asin();
646        let sx = before[0].sin();
647        let sz = before[2].sin();
648        if sx.abs() < sz.abs() {
649            let cx = before[0].cos();
650            result = if cx > 0.0 {
651                [0.0, y, (-mat[3]).asin()]
652            } else {
653                [std::f32::consts::PI, y, mat[3].asin()]
654            };
655        } else {
656            let cz = before[2].cos();
657            result = if cz > 0.0 {
658                [(-mat[7]).asin(), y, 0.0]
659            } else {
660                [mat[7].asin(), y, std::f32::consts::PI]
661            };
662        }
663    } else {
664        result = [mat[5].atan2(mat[8]), (-mat[2]).asin(), mat[1].atan2(mat[0])];
665    }
666
667    let pi = std::f32::consts::PI;
668    let candidates: [[f32; 3]; 8] = [
669        [result[0] + pi, pi - result[1], result[2] + pi],
670        [result[0] + pi, pi - result[1], result[2] - pi],
671        [result[0] + pi, -pi - result[1], result[2] + pi],
672        [result[0] + pi, -pi - result[1], result[2] - pi],
673        [result[0] - pi, pi - result[1], result[2] + pi],
674        [result[0] - pi, pi - result[1], result[2] - pi],
675        [result[0] - pi, -pi - result[1], result[2] + pi],
676        [result[0] - pi, -pi - result[1], result[2] - pi],
677    ];
678    let mut min_error = diff_angle(result[0], before[0]).abs()
679        + diff_angle(result[1], before[1]).abs()
680        + diff_angle(result[2], before[2]).abs();
681    for candidate in &candidates {
682        let error = diff_angle(candidate[0], before[0]).abs()
683            + diff_angle(candidate[1], before[1]).abs()
684            + diff_angle(candidate[2], before[2]).abs();
685        if error < min_error {
686            min_error = error;
687            result = *candidate;
688        }
689    }
690    result
691}
692
693fn diff_angle(a: f32, b: f32) -> f32 {
694    let diff = normalize_angle(a) - normalize_angle(b);
695    if diff > std::f32::consts::PI {
696        diff - std::f32::consts::TAU
697    } else if diff < -std::f32::consts::PI {
698        diff + std::f32::consts::TAU
699    } else {
700        diff
701    }
702}
703
704fn normalize_angle(angle: f32) -> f32 {
705    let mut result = angle;
706    while result >= std::f32::consts::TAU {
707        result -= std::f32::consts::TAU;
708    }
709    while result < 0.0 {
710        result += std::f32::consts::TAU;
711    }
712    result
713}
714
715pub(crate) fn euler_xyz_to_quat(euler: &[f32; 3]) -> Quat {
716    let [x, y, z] = *euler;
717    let c1 = (x / 2.0).cos();
718    let c2 = (y / 2.0).cos();
719    let c3 = (z / 2.0).cos();
720    let s1 = (x / 2.0).sin();
721    let s2 = (y / 2.0).sin();
722    let s3 = (z / 2.0).sin();
723    Quat::from_xyzw(
724        s1 * c2 * c3 + c1 * s2 * s3,
725        c1 * s2 * c3 - s1 * c2 * s3,
726        c1 * c2 * s3 + s1 * s2 * c3,
727        c1 * c2 * c3 - s1 * s2 * s3,
728    )
729}
730
731pub(crate) struct LimitedAxesLinkStepInput<'a> {
732    pub local_effector: &'a Vec3A,
733    pub local_target: &'a Vec3A,
734    pub link_index: usize,
735    pub base_rotations: &'a [Quat],
736    pub ik_rotations: &'a mut [Quat],
737    pub chain_states: &'a mut [ChainLinkState],
738    pub limits: IkAngleLimit,
739    pub limit_angle: f32,
740    pub local_axis_basis: Option<Quat>,
741}
742
743pub(crate) fn solve_limited_axes_link_step(input: LimitedAxesLinkStepInput<'_>) {
744    let state = &mut input.chain_states[input.link_index];
745    let base = input.base_rotations[input.link_index];
746    let current = (input.ik_rotations[input.link_index] * base).normalize();
747    // Evaluate Euler / axis limits in the optional local-axis frame, then map
748    // the clamped rotation back to bone-local space.
749    let (q_b, q_b_inv) = match input.local_axis_basis {
750        Some(basis) if basis.is_finite() => (basis.normalize(), basis.normalize().inverse()),
751        _ => (Quat::IDENTITY, Quat::IDENTITY),
752    };
753    let current_la = (q_b_inv * current * q_b).normalize();
754    let current_mat = quat_to_rotation_mat3(current_la);
755    let mut total_euler = decompose_euler_xyz(&current_mat, &state.previous_euler);
756    let mut working_effector = q_b_inv.mul_vec3a(*input.local_effector);
757    let target = q_b_inv.mul_vec3a(*input.local_target).normalize();
758
759    for axis_index in [2usize, 1, 0] {
760        let (lower, upper) = limit_axis_bounds(input.limits, axis_index);
761        if lower == 0.0 && upper == 0.0 {
762            let next = total_euler[axis_index].clamp(lower, upper);
763            let applied = next - total_euler[axis_index];
764            total_euler[axis_index] = next;
765            if applied.abs() > 0.0 {
766                working_effector = Quat::from_axis_angle(axis_vec(axis_index).into(), applied)
767                    .mul_vec3a(working_effector);
768            }
769            continue;
770        }
771
772        let axis = axis_vec(axis_index);
773        let signed_angle = signed_projected_angle(working_effector, target, axis);
774        if signed_angle.abs() <= 1.0e-6 {
775            continue;
776        }
777        let step = if input.limit_angle > 0.0 {
778            signed_angle.clamp(-input.limit_angle, input.limit_angle)
779        } else {
780            signed_angle
781        };
782        let next = (total_euler[axis_index] + step).clamp(lower, upper);
783        let applied = next - total_euler[axis_index];
784        total_euler[axis_index] = next;
785        if applied.abs() > 0.0 {
786            working_effector =
787                Quat::from_axis_angle(axis.into(), applied).mul_vec3a(working_effector);
788        }
789    }
790
791    state.previous_euler = total_euler;
792    let chain_rotation_la = euler_xyz_to_quat(&total_euler).normalize();
793    let chain_rotation = (q_b * chain_rotation_la * q_b_inv).normalize();
794    input.ik_rotations[input.link_index] = (chain_rotation * base.inverse()).normalize();
795}
796
797pub(crate) fn limit_axis_bounds(limits: IkAngleLimit, axis_index: usize) -> (f32, f32) {
798    match axis_index {
799        0 => (limits.min.x, limits.max.x),
800        1 => (limits.min.y, limits.max.y),
801        _ => (limits.min.z, limits.max.z),
802    }
803}
804
805pub(crate) fn axis_vec(axis_index: usize) -> Vec3A {
806    match axis_index {
807        0 => Vec3A::new(1.0, 0.0, 0.0),
808        1 => Vec3A::new(0.0, 1.0, 0.0),
809        _ => Vec3A::new(0.0, 0.0, 1.0),
810    }
811}
812
813pub(crate) fn signed_projected_angle(from: Vec3A, to: Vec3A, axis: Vec3A) -> f32 {
814    let projected_from = from - axis * from.dot(axis);
815    let projected_to = to - axis * to.dot(axis);
816    if projected_from.length_squared() <= f32::EPSILON
817        || projected_to.length_squared() <= f32::EPSILON
818    {
819        return 0.0;
820    }
821    let from_n = projected_from.normalize();
822    let to_n = projected_to.normalize();
823    let dot = from_n.dot(to_n).clamp(-1.0, 1.0);
824    let angle = dot.acos();
825    let sign = axis.dot(from_n.cross(to_n)).signum();
826    angle * if sign == 0.0 { 1.0 } else { sign }
827}
828
829pub(crate) struct PlaneLinkStepInput<'a> {
830    pub local_effector: &'a Vec3A,
831    pub local_target: &'a Vec3A,
832    pub link_index: usize,
833    pub base_rotations: &'a [Quat],
834    pub ik_rotations: &'a mut [Quat],
835    pub chain_states: &'a mut [ChainLinkState],
836    pub axis_index: usize,
837    pub limits: IkAngleLimit,
838    pub iteration: usize,
839    pub limit_angle: f32,
840    pub local_axis_basis: Option<Quat>,
841}
842
843pub(crate) fn solve_plane_link_step(input: PlaneLinkStepInput<'_>) {
844    let rotate_axis_la = match input.axis_index {
845        0 => Vec3A::new(1.0, 0.0, 0.0),
846        1 => Vec3A::new(0.0, 1.0, 0.0),
847        _ => Vec3A::new(0.0, 0.0, 1.0),
848    };
849    let (q_b, q_b_inv) = match input.local_axis_basis {
850        Some(basis) if basis.is_finite() => (basis.normalize(), basis.normalize().inverse()),
851        _ => (Quat::IDENTITY, Quat::IDENTITY),
852    };
853    // Solve the plane limit in local-axis space, then conjugate back.
854    let local_eff_n = q_b_inv.mul_vec3a(*input.local_effector).normalize();
855    let local_tgt_n = q_b_inv.mul_vec3a(*input.local_target).normalize();
856
857    let dot = local_eff_n.dot(local_tgt_n).clamp(-1.0, 1.0);
858    let raw_angle = dot.acos();
859    let capped_angle = if input.limit_angle > 0.0 {
860        raw_angle.min(input.limit_angle)
861    } else {
862        raw_angle
863    };
864
865    let target_vec1 =
866        Quat::from_axis_angle(rotate_axis_la.into(), capped_angle).mul_vec3a(local_eff_n);
867    let target_vec2 =
868        Quat::from_axis_angle(rotate_axis_la.into(), -capped_angle).mul_vec3a(local_eff_n);
869    let signed_angle = if target_vec1.dot(local_tgt_n) > target_vec2.dot(local_tgt_n) {
870        capped_angle
871    } else {
872        -capped_angle
873    };
874
875    let state = &mut input.chain_states[input.link_index];
876    let mut next_angle = state.plane_mode_angle + signed_angle;
877    let (lower, upper) = match input.axis_index {
878        0 => (input.limits.min.x, input.limits.max.x),
879        1 => (input.limits.min.y, input.limits.max.y),
880        _ => (input.limits.min.z, input.limits.max.z),
881    };
882    let base = input.base_rotations[input.link_index];
883
884    if input.iteration == 0 && (next_angle < lower || next_angle > upper) {
885        if -next_angle > lower && -next_angle < upper {
886            next_angle = -next_angle;
887        } else {
888            let half = (lower + upper) * 0.5;
889            if (half - next_angle).abs() > (half + next_angle).abs() {
890                next_angle = -next_angle;
891            }
892        }
893    }
894
895    state.plane_mode_angle = next_angle.clamp(lower, upper);
896    let chain_rotation_la = Quat::from_axis_angle(rotate_axis_la.into(), state.plane_mode_angle);
897    let chain_rotation = (q_b * chain_rotation_la * q_b_inv).normalize();
898    input.ik_rotations[input.link_index] = (chain_rotation * base.inverse()).normalize();
899}
900
901#[cfg(test)]
902mod tests {
903    use std::sync::Arc;
904
905    use super::*;
906    use crate::{BoneIndex, BoneInit, IkLinkInit, IkSolverInit, ModelArena, RuntimeInstance};
907
908    fn assert_vec3a_near(actual: Vec3A, expected: Vec3A) {
909        let delta = (actual - expected).abs();
910        assert!(
911            delta.x < 1.0e-5 && delta.y < 1.0e-5 && delta.z < 1.0e-5,
912            "actual={actual:?} expected={expected:?} delta={delta:?}"
913        );
914    }
915
916    fn assert_quat_near(actual: Quat, expected: Quat) {
917        let actual = actual.to_array();
918        let expected = expected.to_array();
919        let delta = [
920            (actual[0] - expected[0]).abs(),
921            (actual[1] - expected[1]).abs(),
922            (actual[2] - expected[2]).abs(),
923            (actual[3] - expected[3]).abs(),
924        ];
925        assert!(
926            delta[0] < 1.0e-5 && delta[1] < 1.0e-5 && delta[2] < 1.0e-5 && delta[3] < 1.0e-5,
927            "actual={actual:?} expected={expected:?} delta={delta:?}"
928        );
929    }
930
931    fn one_link_definition(angle_limit: Option<IkAngleLimit>) -> IkChainDefinition {
932        IkChainDefinition {
933            parent_slots: vec![None, Some(0)],
934            rest_positions: vec![Vec3A::ZERO, Vec3A::X],
935            fixed_axes: vec![None, None],
936            target_slot: 1,
937            links: vec![IkChainLinkDefinition {
938                bone_slot: 0,
939                angle_limit,
940            }],
941            iteration_count: 1,
942            limit_angle: 0.0,
943        }
944    }
945
946    #[test]
947    fn mini_chain_world_update_uses_identity_when_parent_world_is_unspecified() {
948        let definition = one_link_definition(None);
949        let mut world = vec![Mat4::IDENTITY; 2];
950        update_mini_chain_world_matrices(
951            &definition,
952            Mat4::IDENTITY,
953            &[Vec3A::ZERO, Vec3A::new(0.0, 2.0, 0.0)],
954            &[
955                Quat::from_rotation_z(std::f32::consts::FRAC_PI_2),
956                Quat::IDENTITY,
957            ],
958            &mut world,
959        );
960
961        assert_vec3a_near(translation(world[1]), Vec3A::new(-2.0, 1.0, 0.0));
962    }
963
964    #[test]
965    fn primitive_matches_full_runtime_for_unconstrained_chain() {
966        let model = Arc::new(
967            ModelArena::new_with_ik(
968                vec![
969                    BoneInit::new(None, Vec3A::ZERO),
970                    BoneInit::new(Some(BoneIndex(0)), Vec3A::X),
971                    BoneInit::new(None, Vec3A::Y),
972                ],
973                vec![IkSolverInit {
974                    ik_bone: BoneIndex(2),
975                    target_bone: BoneIndex(1),
976                    links: vec![IkLinkInit::new(BoneIndex(0))],
977                    iteration_count: 1,
978                    limit_angle: 0.0,
979                }],
980            )
981            .unwrap(),
982        );
983        let mut runtime = RuntimeInstance::new(model);
984        runtime.evaluate_current_pose();
985
986        let mut solver = IkChainSolver::new(one_link_definition(None));
987        let local_position_offsets = [Vec3A::ZERO; 2];
988        let local_rotations = [Quat::IDENTITY; 2];
989        let output = solver.solve(IkChainPoseInput {
990            parent_world_matrix: None,
991            local_position_offsets: &local_position_offsets,
992            local_rotations: &local_rotations,
993            goal_position: Vec3A::Y,
994            tolerance: 1.0e-2,
995            max_iterations_cap: None,
996        });
997
998        assert_quat_near(
999            output.solved_link_rotations[0],
1000            runtime.pose().local_rotation(BoneIndex(0)),
1001        );
1002    }
1003
1004    #[test]
1005    fn primitive_matches_full_runtime_for_two_link_unconstrained_chain() {
1006        let model = Arc::new(
1007            ModelArena::new_with_ik(
1008                vec![
1009                    BoneInit::new(None, Vec3A::ZERO),
1010                    BoneInit::new(Some(BoneIndex(0)), Vec3A::X),
1011                    BoneInit::new(Some(BoneIndex(1)), Vec3A::X),
1012                    BoneInit::new(None, Vec3A::new(1.0, 1.0, 0.0)),
1013                ],
1014                vec![IkSolverInit {
1015                    ik_bone: BoneIndex(3),
1016                    target_bone: BoneIndex(2),
1017                    links: vec![IkLinkInit::new(BoneIndex(1)), IkLinkInit::new(BoneIndex(0))],
1018                    iteration_count: 4,
1019                    limit_angle: 0.0,
1020                }],
1021            )
1022            .unwrap(),
1023        );
1024        let mut runtime = RuntimeInstance::new(model);
1025        runtime.evaluate_current_pose();
1026
1027        let definition = IkChainDefinition {
1028            parent_slots: vec![None, Some(0), Some(1)],
1029            rest_positions: vec![Vec3A::ZERO, Vec3A::X, Vec3A::X],
1030            fixed_axes: vec![None, None, None],
1031            target_slot: 2,
1032            links: vec![
1033                IkChainLinkDefinition {
1034                    bone_slot: 1,
1035                    angle_limit: None,
1036                },
1037                IkChainLinkDefinition {
1038                    bone_slot: 0,
1039                    angle_limit: None,
1040                },
1041            ],
1042            iteration_count: 4,
1043            limit_angle: 0.0,
1044        };
1045        let mut solver = IkChainSolver::new(definition);
1046        let local_position_offsets = [Vec3A::ZERO; 3];
1047        let local_rotations = [Quat::IDENTITY; 3];
1048        let output = solver.solve(IkChainPoseInput {
1049            parent_world_matrix: None,
1050            local_position_offsets: &local_position_offsets,
1051            local_rotations: &local_rotations,
1052            goal_position: Vec3A::new(1.0, 1.0, 0.0),
1053            tolerance: 1.0e-2,
1054            max_iterations_cap: None,
1055        });
1056
1057        assert_quat_near(
1058            output.solved_link_rotations[0],
1059            runtime.pose().local_rotation(BoneIndex(1)),
1060        );
1061        assert_quat_near(
1062            output.solved_link_rotations[1],
1063            runtime.pose().local_rotation(BoneIndex(0)),
1064        );
1065    }
1066
1067    #[test]
1068    fn deform_order_characterization_keeps_known_ik_delta_bounded() {
1069        fn solve_one_pass(
1070            definition: &IkChainDefinition,
1071            goal_position: Vec3A,
1072            strict: bool,
1073        ) -> Vec<Quat> {
1074            let local_position_offsets = vec![Vec3A::ZERO; definition.rest_positions.len()];
1075            let mut local_rotations = vec![Quat::IDENTITY; definition.rest_positions.len()];
1076            let base_rotations = vec![Quat::IDENTITY; definition.links.len()];
1077            let mut ik_rotations = vec![Quat::IDENTITY; definition.links.len()];
1078            let mut chain_states = vec![ChainLinkState::default(); definition.links.len()];
1079            let mut world_matrices = vec![Mat4::IDENTITY; definition.rest_positions.len()];
1080
1081            update_mini_chain_world_matrices(
1082                definition,
1083                Mat4::IDENTITY,
1084                &local_position_offsets,
1085                &local_rotations,
1086                &mut world_matrices,
1087            );
1088
1089            for link_index in 0..definition.links.len() {
1090                let link = &definition.links[link_index];
1091                let link_slot = link.bone_slot;
1092                let link_world = world_matrices[link_slot];
1093                let link_pos = translation(link_world);
1094                let eff_pos = translation(world_matrices[definition.target_slot]);
1095                let link_world_rot = rotation(link_world);
1096                let local_effector = link_world_rot.inverse().mul_vec3a(eff_pos - link_pos);
1097                let local_target = link_world_rot.inverse().mul_vec3a(goal_position - link_pos);
1098
1099                let fixed_axis = definition.fixed_axes.get(link_slot).copied().flatten();
1100                solve_link_step(LinkStepInput {
1101                    local_effector: &local_effector,
1102                    local_target: &local_target,
1103                    link_index,
1104                    base_rotations: &base_rotations,
1105                    ik_rotations: &mut ik_rotations,
1106                    chain_states: &mut chain_states,
1107                    angle_limit: link.angle_limit,
1108                    iteration: 0,
1109                    limit_angle: definition.limit_angle,
1110                    local_axis_basis: None,
1111                    fixed_axis,
1112                });
1113
1114                if strict {
1115                    local_rotations[link_slot] = ik_rotations[link_index].normalize();
1116                    update_mini_chain_world_matrices(
1117                        definition,
1118                        Mat4::IDENTITY,
1119                        &local_position_offsets,
1120                        &local_rotations,
1121                        &mut world_matrices,
1122                    );
1123                }
1124            }
1125
1126            if !strict {
1127                for (link_index, link) in definition.links.iter().enumerate() {
1128                    local_rotations[link.bone_slot] = ik_rotations[link_index].normalize();
1129                }
1130                update_mini_chain_world_matrices(
1131                    definition,
1132                    Mat4::IDENTITY,
1133                    &local_position_offsets,
1134                    &local_rotations,
1135                    &mut world_matrices,
1136                );
1137            }
1138
1139            definition
1140                .links
1141                .iter()
1142                .map(|link| local_rotations[link.bone_slot])
1143                .collect()
1144        }
1145
1146        let definition = IkChainDefinition {
1147            parent_slots: vec![None, Some(0), Some(1)],
1148            rest_positions: vec![Vec3A::ZERO, Vec3A::X, Vec3A::X],
1149            fixed_axes: vec![None, None, None],
1150            target_slot: 2,
1151            links: vec![
1152                IkChainLinkDefinition {
1153                    bone_slot: 1,
1154                    angle_limit: None,
1155                },
1156                IkChainLinkDefinition {
1157                    bone_slot: 0,
1158                    angle_limit: None,
1159                },
1160            ],
1161            iteration_count: 1,
1162            limit_angle: 0.0,
1163        };
1164        let goal_position = Vec3A::new(1.0, 1.0, 0.0);
1165
1166        let correct_order = solve_one_pass(&definition, goal_position, true);
1167        let dependency_order = solve_one_pass(&definition, goal_position, false);
1168        let max_angular_delta = correct_order
1169            .iter()
1170            .zip(&dependency_order)
1171            .map(|(correct, dependency)| correct.angle_between(*dependency))
1172            .fold(0.0f32, f32::max);
1173
1174        assert!(
1175            max_angular_delta > 0.0,
1176            "fixture must characterize a non-zero strict-order vs dependency-order IK delta"
1177        );
1178        assert!(
1179            max_angular_delta <= 0.79,
1180            "characterization budget widened unexpectedly: max_angular_delta={max_angular_delta}"
1181        );
1182    }
1183
1184    #[test]
1185    fn primitive_matches_full_runtime_for_knee_plane_limit() {
1186        let limit = IkAngleLimit::new(
1187            Vec3A::new(0.0, 0.0, 0.0),
1188            Vec3A::new(0.0, 0.0, std::f32::consts::FRAC_PI_4),
1189        );
1190        let model = Arc::new(
1191            ModelArena::new_with_ik(
1192                vec![
1193                    BoneInit::new(None, Vec3A::ZERO),
1194                    BoneInit::new(Some(BoneIndex(0)), Vec3A::X),
1195                    BoneInit::new(None, Vec3A::Y),
1196                ],
1197                vec![IkSolverInit {
1198                    ik_bone: BoneIndex(2),
1199                    target_bone: BoneIndex(1),
1200                    links: vec![IkLinkInit::new(BoneIndex(0)).with_angle_limit(limit)],
1201                    iteration_count: 1,
1202                    limit_angle: 0.0,
1203                }],
1204            )
1205            .unwrap(),
1206        );
1207        let mut runtime = RuntimeInstance::new(model);
1208        runtime.evaluate_current_pose();
1209
1210        let mut solver = IkChainSolver::new(one_link_definition(Some(limit)));
1211        let local_position_offsets = [Vec3A::ZERO; 2];
1212        let local_rotations = [Quat::IDENTITY; 2];
1213        let output = solver.solve(IkChainPoseInput {
1214            parent_world_matrix: None,
1215            local_position_offsets: &local_position_offsets,
1216            local_rotations: &local_rotations,
1217            goal_position: Vec3A::Y,
1218            tolerance: 1.0e-2,
1219            max_iterations_cap: None,
1220        });
1221
1222        assert_quat_near(
1223            output.solved_link_rotations[0],
1224            runtime.pose().local_rotation(BoneIndex(0)),
1225        );
1226    }
1227
1228    #[test]
1229    fn primitive_matches_full_runtime_for_limited_axes_chain() {
1230        let limit = IkAngleLimit::new(Vec3A::new(0.0, -0.6, -0.6), Vec3A::new(0.0, 0.6, 0.6));
1231        let goal = Vec3A::new(0.25, 0.55, 0.80).normalize();
1232        let model = Arc::new(
1233            ModelArena::new_with_ik(
1234                vec![
1235                    BoneInit::new(None, Vec3A::ZERO),
1236                    BoneInit::new(Some(BoneIndex(0)), Vec3A::X),
1237                    BoneInit::new(None, goal),
1238                ],
1239                vec![IkSolverInit {
1240                    ik_bone: BoneIndex(2),
1241                    target_bone: BoneIndex(1),
1242                    links: vec![IkLinkInit::new(BoneIndex(0)).with_angle_limit(limit)],
1243                    iteration_count: 1,
1244                    limit_angle: 0.0,
1245                }],
1246            )
1247            .unwrap(),
1248        );
1249        let mut runtime = RuntimeInstance::new(model);
1250        runtime.evaluate_current_pose();
1251
1252        let mut solver = IkChainSolver::new(one_link_definition(Some(limit)));
1253        let local_position_offsets = [Vec3A::ZERO; 2];
1254        let local_rotations = [Quat::IDENTITY; 2];
1255        let output = solver.solve(IkChainPoseInput {
1256            parent_world_matrix: None,
1257            local_position_offsets: &local_position_offsets,
1258            local_rotations: &local_rotations,
1259            goal_position: goal,
1260            tolerance: 1.0e-2,
1261            max_iterations_cap: None,
1262        });
1263
1264        assert_quat_near(
1265            output.solved_link_rotations[0],
1266            runtime.pose().local_rotation(BoneIndex(0)),
1267        );
1268    }
1269
1270    #[test]
1271    fn primitive_is_bit_deterministic_in_current_process_profile() {
1272        // The workspace currently enables glam fast-math; this test asserts
1273        // bit identity only across repeated solves in this same build profile.
1274        let definition = IkChainDefinition {
1275            parent_slots: vec![None, Some(0), Some(1)],
1276            rest_positions: vec![Vec3A::ZERO, Vec3A::X, Vec3A::X],
1277            fixed_axes: vec![None, None, None],
1278            target_slot: 2,
1279            links: vec![
1280                IkChainLinkDefinition {
1281                    bone_slot: 1,
1282                    angle_limit: None,
1283                },
1284                IkChainLinkDefinition {
1285                    bone_slot: 0,
1286                    angle_limit: None,
1287                },
1288            ],
1289            iteration_count: 4,
1290            limit_angle: 0.0,
1291        };
1292        let local_position_offsets = [Vec3A::ZERO; 3];
1293        let local_rotations = [Quat::IDENTITY; 3];
1294        let input = IkChainPoseInput {
1295            parent_world_matrix: None,
1296            local_position_offsets: &local_position_offsets,
1297            local_rotations: &local_rotations,
1298            goal_position: Vec3A::new(1.0, 1.0, 0.0),
1299            tolerance: 1.0e-2,
1300            max_iterations_cap: None,
1301        };
1302        let mut baseline_solver = IkChainSolver::new(definition.clone());
1303        let expected = baseline_solver.solve(input);
1304
1305        for _ in 0..32 {
1306            let mut solver = IkChainSolver::new(definition.clone());
1307            let actual = solver.solve(input);
1308            assert_eq!(
1309                actual.final_distance.to_bits(),
1310                expected.final_distance.to_bits()
1311            );
1312            assert_eq!(actual.executed_iterations, expected.executed_iterations);
1313            assert_eq!(actual.link_steps, expected.link_steps);
1314            let actual_bits: Vec<_> = actual
1315                .solved_link_rotations
1316                .iter()
1317                .map(|q| q.to_array().map(f32::to_bits))
1318                .collect();
1319            let expected_bits: Vec<_> = expected
1320                .solved_link_rotations
1321                .iter()
1322                .map(|q| q.to_array().map(f32::to_bits))
1323                .collect();
1324            assert_eq!(actual_bits, expected_bits);
1325        }
1326    }
1327
1328    #[test]
1329    fn existing_ik_chain_definition_struct_literal_has_no_local_axes() {
1330        // Source-compatible: no local_axis_bases field required on the public
1331        // definition. IkChainSolver::new leaves all bases as None.
1332        let definition = IkChainDefinition {
1333            parent_slots: vec![None, Some(0)],
1334            rest_positions: vec![Vec3A::ZERO, Vec3A::X],
1335            fixed_axes: vec![None, None],
1336            target_slot: 1,
1337            links: vec![IkChainLinkDefinition {
1338                bone_slot: 0,
1339                angle_limit: None,
1340            }],
1341            iteration_count: 1,
1342            limit_angle: 0.0,
1343        };
1344        let mut solver = IkChainSolver::new(definition);
1345        let output = solver.solve(IkChainPoseInput {
1346            parent_world_matrix: None,
1347            local_position_offsets: &[Vec3A::ZERO; 2],
1348            local_rotations: &[Quat::IDENTITY; 2],
1349            goal_position: Vec3A::Y,
1350            tolerance: 0.0,
1351            max_iterations_cap: None,
1352        });
1353        assert_eq!(output.solved_link_rotations.len(), 1);
1354    }
1355
1356    #[test]
1357    fn fixed_axis_composes_with_single_axis_angle_limit() {
1358        // Without fixed-axis, pure Z plane limit can rotate X toward Y.
1359        // With fixed-axis = Y, CCD may only twist about Y, so X stays on XZ.
1360        let limit = IkAngleLimit::new(
1361            Vec3A::new(0.0, 0.0, -std::f32::consts::FRAC_PI_2),
1362            Vec3A::new(0.0, 0.0, std::f32::consts::FRAC_PI_2),
1363        );
1364        let definition = IkChainDefinition {
1365            parent_slots: vec![None, Some(0)],
1366            rest_positions: vec![Vec3A::ZERO, Vec3A::X],
1367            fixed_axes: vec![Some(Vec3A::Y), None],
1368            target_slot: 1,
1369            links: vec![IkChainLinkDefinition {
1370                bone_slot: 0,
1371                angle_limit: Some(limit),
1372            }],
1373            iteration_count: 4,
1374            limit_angle: 0.0,
1375        };
1376        let mut solver = IkChainSolver::new(definition);
1377        let output = solver.solve(IkChainPoseInput {
1378            parent_world_matrix: None,
1379            local_position_offsets: &[Vec3A::ZERO; 2],
1380            local_rotations: &[Quat::IDENTITY; 2],
1381            goal_position: Vec3A::Y,
1382            tolerance: 0.0,
1383            max_iterations_cap: None,
1384        });
1385        let child = output.solved_link_rotations[0].mul_vec3a(Vec3A::X);
1386        assert!(
1387            child.y.abs() < 1.0e-3,
1388            "fixed Y + Z-plane limit must not lift child off XZ; child={child:?}"
1389        );
1390        // Twist about Y is still free within limits → can move toward +Z goal component.
1391        // Goal is +Y which is unreachable under Y-fixed; stay near rest +X.
1392        assert!(
1393            child.x > 0.9,
1394            "unreachable +Y goal under Y fixed-axis keeps child near +X; child={child:?}"
1395        );
1396    }
1397
1398    #[test]
1399    fn fixed_axis_composes_with_multi_axis_angle_limit() {
1400        let limit = IkAngleLimit::new(Vec3A::new(-1.0, -1.0, 0.0), Vec3A::new(1.0, 1.0, 0.0));
1401        let definition = IkChainDefinition {
1402            parent_slots: vec![None, Some(0)],
1403            rest_positions: vec![Vec3A::ZERO, Vec3A::X],
1404            fixed_axes: vec![Some(Vec3A::Y), None],
1405            target_slot: 1,
1406            links: vec![IkChainLinkDefinition {
1407                bone_slot: 0,
1408                angle_limit: Some(limit),
1409            }],
1410            iteration_count: 4,
1411            limit_angle: 0.0,
1412        };
1413        let mut solver = IkChainSolver::new(definition);
1414        let output = solver.solve(IkChainPoseInput {
1415            parent_world_matrix: None,
1416            local_position_offsets: &[Vec3A::ZERO; 2],
1417            local_rotations: &[Quat::IDENTITY; 2],
1418            // Mild XZ goal avoids Euler singularities near ±π/2 while still
1419            // requiring a non-trivial Y twist under multi-axis limits.
1420            goal_position: Vec3A::new(0.7, 0.0, 0.7),
1421            tolerance: 0.0,
1422            max_iterations_cap: None,
1423        });
1424        let solved = output.solved_link_rotations[0];
1425        let child = solved.mul_vec3a(Vec3A::X);
1426        assert!(
1427            child.y.abs() < 1.0e-3,
1428            "fixed Y + multi-axis limit must keep child on XZ; child={child:?}"
1429        );
1430        // Pure Y twist of the chain rotation: rotation vector parallel to Y.
1431        let rot_vec = Vec3A::new(solved.x, solved.y, solved.z);
1432        if rot_vec.length_squared() > 1.0e-8 {
1433            let axis = rot_vec.normalize();
1434            assert!(
1435                axis.x.abs() < 1.0e-3 && axis.z.abs() < 1.0e-3,
1436                "IK delta must remain pure Y twist; axis={axis:?} quat={solved:?}"
1437            );
1438        }
1439        assert!(
1440            child.z > 0.2,
1441            "fixed-axis multi-limit IK should still approach +Z; child={child:?}"
1442        );
1443    }
1444
1445    #[test]
1446    fn fixed_axis_angle_limit_preserves_correction_with_non_identity_base() {
1447        let limit = IkAngleLimit::new(
1448            Vec3A::splat(-std::f32::consts::PI),
1449            Vec3A::splat(std::f32::consts::PI),
1450        );
1451        let definition = IkChainDefinition {
1452            parent_slots: vec![None, Some(0)],
1453            rest_positions: vec![Vec3A::ZERO, Vec3A::X],
1454            fixed_axes: vec![Some(Vec3A::Y), None],
1455            target_slot: 1,
1456            links: vec![IkChainLinkDefinition {
1457                bone_slot: 0,
1458                angle_limit: Some(limit),
1459            }],
1460            iteration_count: 1,
1461            limit_angle: 0.0,
1462        };
1463        let base = Quat::from_rotation_x(std::f32::consts::FRAC_PI_2);
1464        let goal = Vec3A::Y;
1465        let mut solver = IkChainSolver::new(definition);
1466        let output = solver.solve(IkChainPoseInput {
1467            parent_world_matrix: None,
1468            local_position_offsets: &[Vec3A::ZERO; 2],
1469            local_rotations: &[base, Quat::IDENTITY],
1470            goal_position: goal,
1471            tolerance: 0.0,
1472            max_iterations_cap: None,
1473        });
1474
1475        let solved = output.solved_link_rotations[0];
1476        let child = solved.mul_vec3a(Vec3A::X).normalize();
1477        assert!(
1478            child.dot(goal) > 0.99,
1479            "fixed-axis correction must survive projection in the base-rotated frame; solved={solved:?} child={child:?}"
1480        );
1481    }
1482
1483    #[test]
1484    fn fixed_axis_projection_uses_base_rotated_correction_axis() {
1485        let base = Quat::from_rotation_x(0.7);
1486        let correction_axis = base.mul_vec3a(Vec3A::Y).normalize();
1487        let mut ik_rotations = [(Quat::from_axis_angle(correction_axis.into(), 0.6)
1488            * Quat::from_rotation_x(0.2))
1489        .normalize()];
1490
1491        project_link_rotation_onto_fixed_axis(0, &[base], &mut ik_rotations, Vec3A::Y);
1492
1493        let vector = Vec3A::new(ik_rotations[0].x, ik_rotations[0].y, ik_rotations[0].z);
1494        assert!(
1495            vector.length_squared() > 1.0e-4,
1496            "projection must preserve a non-trivial fixed-axis correction"
1497        );
1498        let projected_axis = vector.normalize();
1499        assert!(
1500            projected_axis.dot(correction_axis).abs() > 0.999,
1501            "projection axis must follow base * fixedAxis; projected={projected_axis:?} expected={correction_axis:?}"
1502        );
1503        assert!(
1504            projected_axis.dot(Vec3A::Y).abs() < 0.95,
1505            "non-identity base must not project the IK correction onto raw fixedAxis"
1506        );
1507    }
1508
1509    #[test]
1510    fn additive_local_axis_bases_change_limited_solve() {
1511        let limits = IkAngleLimit::new(
1512            Vec3A::new(-std::f32::consts::FRAC_PI_2, 0.0, 0.0),
1513            Vec3A::new(std::f32::consts::FRAC_PI_2, 0.0, 0.0),
1514        );
1515        let definition = one_link_definition(Some(limits));
1516        let local_position_offsets = [Vec3A::ZERO; 2];
1517        let local_rotations = [Quat::IDENTITY; 2];
1518        let input = IkChainPoseInput {
1519            parent_world_matrix: None,
1520            local_position_offsets: &local_position_offsets,
1521            local_rotations: &local_rotations,
1522            goal_position: Vec3A::Y,
1523            tolerance: 0.0,
1524            max_iterations_cap: None,
1525        };
1526
1527        let mut unit = IkChainSolver::new(definition.clone());
1528        let unit_out = unit.solve(input);
1529
1530        let basis = Quat::from_rotation_y(-std::f32::consts::FRAC_PI_2);
1531        let mut la = IkChainSolver::new_with_local_axis_bases(definition, vec![Some(basis), None]);
1532        let la_out = la.solve(input);
1533
1534        let unit_dir = unit_out.solved_link_rotations[0]
1535            .mul_vec3a(Vec3A::X)
1536            .normalize();
1537        let la_dir = la_out.solved_link_rotations[0]
1538            .mul_vec3a(Vec3A::X)
1539            .normalize();
1540        assert!(
1541            (unit_dir - la_dir).length() > 0.2,
1542            "additive local-axis bases must change limited solve; unit={unit_dir:?} la={la_dir:?}"
1543        );
1544    }
1545}