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