Skip to main content

mmd_anim_runtime/
runtime.rs

1use std::sync::Arc;
2
3use glam::{Mat4, Quat, Vec3A};
4
5use crate::{AnimationClip, ModelArena, MorphIndex, PoseArena};
6
7#[derive(Debug)]
8struct IkScratch {
9    links: Vec<crate::IkLink>,
10    base_rotations: Vec<Quat>,
11    ik_rotations: Vec<Quat>,
12    best_ik_rotations: Vec<Quat>,
13    chain_states: Vec<ChainLinkState>,
14}
15
16impl IkScratch {
17    fn new(model: &ModelArena) -> Self {
18        let max_links = model
19            .ik_solvers()
20            .iter()
21            .map(|s| s.links.len())
22            .max()
23            .unwrap_or(0);
24        IkScratch {
25            links: Vec::with_capacity(max_links),
26            base_rotations: Vec::with_capacity(max_links),
27            ik_rotations: Vec::with_capacity(max_links),
28            best_ik_rotations: Vec::with_capacity(max_links),
29            chain_states: Vec::with_capacity(max_links),
30        }
31    }
32}
33
34#[derive(Debug)]
35struct MorphScratch {
36    expanded_weights: Vec<f32>,
37}
38
39impl MorphScratch {
40    fn new(morph_count: usize) -> Self {
41        Self {
42            expanded_weights: vec![0.0; morph_count],
43        }
44    }
45}
46
47#[derive(Clone, Copy, Debug, Default, PartialEq)]
48pub struct IkSolverRuntimeStats {
49    pub solver_evaluations: u64,
50    pub configured_iterations: u64,
51    pub executed_iterations: u64,
52    pub tolerance_precheck_breaks: u64,
53    pub tolerance_post_iteration_breaks: u64,
54    pub rollback_breaks: u64,
55    pub max_iteration_exhaustions: u64,
56    pub link_visits: u64,
57    pub link_steps: u64,
58    pub final_distance_sum: f64,
59    pub final_distance_max: f32,
60    pub exhausted_final_distance_sum: f64,
61    pub exhausted_final_distance_max: f32,
62}
63
64impl IkSolverRuntimeStats {
65    fn reset(&mut self) {
66        *self = Self::default();
67    }
68}
69
70#[derive(Clone, Copy, Debug, PartialEq)]
71pub struct IkSolveOptions {
72    pub tolerance: f32,
73    pub max_iterations_cap: Option<u32>,
74}
75
76impl Default for IkSolveOptions {
77    fn default() -> Self {
78        Self {
79            tolerance: 1.0e-2,
80            max_iterations_cap: None,
81        }
82    }
83}
84
85#[derive(Debug)]
86pub struct RuntimeInstance {
87    model: Arc<ModelArena>,
88    pose: PoseArena,
89    ik_scratch: IkScratch,
90    morph_scratch: MorphScratch,
91    ik_stats: Vec<IkSolverRuntimeStats>,
92    #[cfg(test)]
93    world_matrix_bone_update_count: usize,
94}
95
96impl RuntimeInstance {
97    pub fn new(model: Arc<ModelArena>) -> Self {
98        let morph_count = model.morph_count() as usize;
99        Self::new_with_morph_count(model, morph_count)
100    }
101
102    pub fn new_with_morph_count(model: Arc<ModelArena>, morph_count: usize) -> Self {
103        let ik_count = model.ik_count();
104        Self::new_with_counts(model, morph_count, ik_count)
105    }
106
107    pub fn new_with_counts(model: Arc<ModelArena>, morph_count: usize, ik_count: usize) -> Self {
108        let morph_count = morph_count.max(model.morph_count() as usize);
109        let pose = PoseArena::new_with_counts(model.bone_count(), morph_count, ik_count);
110        let ik_scratch = IkScratch::new(&model);
111        let morph_scratch = MorphScratch::new(morph_count);
112        let ik_stats = vec![IkSolverRuntimeStats::default(); model.ik_count()];
113        Self {
114            model,
115            pose,
116            ik_scratch,
117            morph_scratch,
118            ik_stats,
119            #[cfg(test)]
120            world_matrix_bone_update_count: 0,
121        }
122    }
123
124    #[inline]
125    pub fn model(&self) -> &ModelArena {
126        &self.model
127    }
128
129    #[inline]
130    pub fn pose(&self) -> &PoseArena {
131        &self.pose
132    }
133
134    #[inline]
135    pub fn pose_mut(&mut self) -> &mut PoseArena {
136        &mut self.pose
137    }
138
139    pub fn evaluate_current_pose(&mut self) {
140        self.update_world_matrices();
141        self.solve_enabled_ik(IkSolveOptions::default());
142    }
143
144    pub fn evaluate_current_pose_with_ik_options(&mut self, options: IkSolveOptions) {
145        self.update_world_matrices();
146        self.solve_enabled_ik(options);
147    }
148
149    /// Evaluate the current pose by updating world matrices only, without
150    /// running any IK solver. This is useful for diagnostics that need to
151    /// inspect clip/VMD state before IK is applied.
152    pub fn evaluate_current_pose_without_ik(&mut self) {
153        self.update_world_matrices();
154    }
155
156    fn update_world_matrices(&mut self) {
157        self.update_world_matrices_from_eval_order_position(0);
158    }
159
160    fn update_world_matrices_from_bone(&mut self, bone: crate::BoneIndex) {
161        self.update_world_matrices_from_eval_order_position(self.model.eval_order_position(bone));
162    }
163
164    fn update_world_matrices_from_eval_order_position(&mut self, start_position: usize) {
165        let start_position = self.expand_update_start_for_append_dependencies(start_position);
166        for bone in &self.model.eval_order()[start_position..] {
167            self.pose.reset_append_transform(*bone);
168        }
169        for bone in &self.model.eval_order()[start_position..] {
170            #[cfg(test)]
171            {
172                self.world_matrix_bone_update_count += 1;
173            }
174            let mut local_position =
175                self.model.rest_position(*bone) + self.pose.local_position_offset(*bone);
176            let mut local_rotation = self.pose.local_rotation(*bone);
177            let local_scale = self.pose.local_scale(*bone);
178
179            if let Some(append_index) = self.model.append_transform_index(*bone) {
180                let append = self.model.append_transform(append_index);
181                if append.affect_rotation {
182                    let source_rotation = if !append.local
183                        && self
184                            .model
185                            .append_transform_index(append.source_bone)
186                            .is_some()
187                    {
188                        self.pose.append_rotation(append.source_bone)
189                    } else {
190                        self.pose.local_rotation(append.source_bone)
191                    };
192                    let append_rotation = Quat::IDENTITY
193                        .slerp(source_rotation, append.ratio)
194                        .normalize();
195                    self.pose.set_append_rotation(*bone, append_rotation);
196                    local_rotation = (local_rotation * append_rotation).normalize();
197                }
198
199                if append.affect_translation {
200                    let source_position_offset = if !append.local
201                        && self
202                            .model
203                            .append_transform_index(append.source_bone)
204                            .is_some()
205                    {
206                        self.pose.append_position_offset(append.source_bone)
207                    } else {
208                        self.pose.local_position_offset(append.source_bone)
209                    };
210                    let append_position_offset = source_position_offset * append.ratio;
211                    self.pose
212                        .set_append_position_offset(*bone, append_position_offset);
213                    local_position += append_position_offset;
214                }
215            }
216
217            if let Some(axis) = self.model.fixed_axis(*bone) {
218                local_rotation = constrain_rotation_to_axis(local_rotation, axis);
219            }
220
221            let local_matrix = Mat4::from_scale_rotation_translation(
222                local_scale.into(),
223                local_rotation,
224                local_position.into(),
225            );
226
227            let world_matrix = match self.model.parent_index(*bone) {
228                Some(parent) => self.pose.world_matrices()[parent.as_usize()] * local_matrix,
229                None => local_matrix,
230            };
231
232            self.pose.set_world_matrix(*bone, world_matrix);
233            self.pose
234                .set_skinning_matrix(*bone, world_matrix * self.model.inverse_bind_matrix(*bone));
235        }
236    }
237
238    fn expand_update_start_for_append_dependencies(&self, start_position: usize) -> usize {
239        let mut start = start_position;
240        loop {
241            let mut changed = false;
242            for append in self.model.append_transforms() {
243                let source_position = self.model.eval_order_position(append.source_bone);
244                let target_position = self.model.eval_order_position(append.target_bone);
245                if source_position >= start && target_position < start {
246                    start = target_position;
247                    changed = true;
248                }
249            }
250            if !changed {
251                return start;
252            }
253        }
254    }
255
256    fn min_link_eval_order_position(&self, links: &[crate::IkLink]) -> Option<usize> {
257        links
258            .iter()
259            .map(|link| self.model.eval_order_position(link.bone))
260            .min()
261    }
262
263    fn solve_enabled_ik(&mut self, options: IkSolveOptions) {
264        let tolerance = options.tolerance.max(0.0);
265        let mut links = std::mem::take(&mut self.ik_scratch.links);
266        let mut base_rotations = std::mem::take(&mut self.ik_scratch.base_rotations);
267        let mut ik_rotations = std::mem::take(&mut self.ik_scratch.ik_rotations);
268        let mut best_ik_rotations = std::mem::take(&mut self.ik_scratch.best_ik_rotations);
269        let mut chain_states = std::mem::take(&mut self.ik_scratch.chain_states);
270
271        for ik_index in 0..self.model.ik_count() {
272            if self.pose.ik_enabled()[ik_index] == 0 {
273                continue;
274            }
275
276            let solver = &self.model.ik_solvers()[ik_index];
277            let ik_bone = solver.ik_bone;
278            let target_bone = solver.target_bone;
279            let iteration_count = options
280                .max_iterations_cap
281                .map(|cap| solver.iteration_count.min(cap))
282                .unwrap_or(solver.iteration_count)
283                .max(1) as usize;
284            let limit_angle = solver.limit_angle.max(0.0);
285            let link_count = solver.links.len();
286
287            links.clear();
288            links.extend(solver.links.iter().cloned());
289            self.ik_stats[ik_index].solver_evaluations += 1;
290            self.ik_stats[ik_index].configured_iterations += iteration_count as u64;
291
292            base_rotations.clear();
293            base_rotations.extend(links.iter().map(|l| self.pose.local_rotation(l.bone)));
294            ik_rotations.clear();
295            ik_rotations.resize(link_count, Quat::IDENTITY);
296            best_ik_rotations.clear();
297            best_ik_rotations.resize(link_count, Quat::IDENTITY);
298            chain_states.clear();
299            chain_states.resize_with(link_count, || ChainLinkState {
300                previous_euler: [0.0; 3],
301                plane_mode_angle: 0.0,
302            });
303
304            // Always start from base rotations (IK deltas start at identity).
305            self.apply_ik_link_rotations(&links, &base_rotations, &ik_rotations);
306            if let Some(start_position) = self.min_link_eval_order_position(&links) {
307                self.update_world_matrices_from_eval_order_position(start_position);
308            } else {
309                self.update_world_matrices();
310            }
311
312            let mut broke_early = false;
313            let mut final_distance = f32::MAX;
314            let mut best_distance = f32::MAX;
315            for _iteration in 0..iteration_count {
316                // Tolerance early exit
317                let eff_pos = translation(self.pose.world_matrices()[target_bone.as_usize()]);
318                let ik_pos = translation(self.pose.world_matrices()[ik_bone.as_usize()]);
319                final_distance = (eff_pos - ik_pos).length();
320                if final_distance <= tolerance {
321                    self.ik_stats[ik_index].tolerance_precheck_breaks += 1;
322                    broke_early = true;
323                    break;
324                }
325                self.ik_stats[ik_index].executed_iterations += 1;
326
327                for link_index in 0..link_count {
328                    let link = &links[link_index];
329                    let link_bone = link.bone;
330                    self.ik_stats[ik_index].link_visits += 1;
331
332                    if link_bone == target_bone {
333                        continue;
334                    }
335
336                    let link_world = self.pose.world_matrices()[link_bone.as_usize()];
337                    let link_pos = translation(link_world);
338                    let eff_pos = translation(self.pose.world_matrices()[target_bone.as_usize()]);
339                    let ik_pos = translation(self.pose.world_matrices()[ik_bone.as_usize()]);
340
341                    // Transform direction vectors to link-local space
342                    let link_world_rot = rotation(link_world);
343                    let local_effector = link_world_rot.inverse().mul_vec3a(eff_pos - link_pos);
344                    let local_target = link_world_rot.inverse().mul_vec3a(ik_pos - link_pos);
345
346                    if local_effector.length_squared() <= f32::EPSILON
347                        || local_target.length_squared() <= f32::EPSILON
348                    {
349                        continue;
350                    }
351
352                    let single_axis = get_single_axis_limit(link.angle_limit);
353
354                    if let (Some(angle_limit), Some(axis_index)) = (link.angle_limit, single_axis) {
355                        solve_plane_link_step(PlaneLinkStepInput {
356                            local_effector: &local_effector,
357                            local_target: &local_target,
358                            link_index,
359                            base_rotations: &base_rotations,
360                            ik_rotations: &mut ik_rotations,
361                            chain_states: &mut chain_states,
362                            axis_index,
363                            limits: angle_limit,
364                            iteration: _iteration,
365                            limit_angle,
366                        });
367                    } else if let Some(angle_limit) = link.angle_limit {
368                        solve_limited_axes_link_step(LimitedAxesLinkStepInput {
369                            local_effector: &local_effector,
370                            local_target: &local_target,
371                            link_index,
372                            base_rotations: &base_rotations,
373                            ik_rotations: &mut ik_rotations,
374                            chain_states: &mut chain_states,
375                            limits: angle_limit,
376                            limit_angle,
377                        });
378                    } else {
379                        let local_eff_n = local_effector.normalize();
380                        let local_tgt_n = local_target.normalize();
381                        let dot = local_eff_n.dot(local_tgt_n).clamp(-1.0, 1.0);
382                        let mut angle = dot.acos();
383
384                        let tiny_angle = 1e-3 * std::f32::consts::PI / 180.0;
385                        if angle < tiny_angle {
386                            continue;
387                        }
388
389                        if limit_angle > 0.0 {
390                            angle = angle.min(limit_angle);
391                        }
392
393                        let axis = local_eff_n.cross(local_tgt_n);
394                        let axis_vec = if axis.length() < 1e-5 {
395                            if dot > -1.0 + 1e-5 {
396                                continue;
397                            }
398                            let basis = if local_eff_n.x.abs() < 0.9 {
399                                Vec3A::new(1.0, 0.0, 0.0)
400                            } else {
401                                Vec3A::new(0.0, 1.0, 0.0)
402                            };
403                            local_eff_n.cross(basis).normalize()
404                        } else {
405                            axis.normalize()
406                        };
407
408                        let delta = Quat::from_axis_angle(axis_vec.into(), angle);
409                        let base = base_rotations[link_index];
410                        let ik = ik_rotations[link_index];
411                        let chain_rotation = (ik * base * delta).normalize();
412
413                        ik_rotations[link_index] = (chain_rotation * base.inverse()).normalize();
414                    }
415
416                    self.apply_ik_link_rotations(&links, &base_rotations, &ik_rotations);
417                    self.update_world_matrices_from_bone(link_bone);
418                    self.ik_stats[ik_index].link_steps += 1;
419                }
420
421                // Best rotations tracking
422                let current_distance = {
423                    let eff = translation(self.pose.world_matrices()[target_bone.as_usize()]);
424                    let ik = translation(self.pose.world_matrices()[ik_bone.as_usize()]);
425                    (eff - ik).length()
426                };
427                final_distance = current_distance;
428
429                if current_distance < best_distance {
430                    best_distance = current_distance;
431                    best_ik_rotations.copy_from_slice(&ik_rotations);
432                    if current_distance <= tolerance {
433                        self.ik_stats[ik_index].tolerance_post_iteration_breaks += 1;
434                        broke_early = true;
435                        break;
436                    }
437                } else {
438                    self.ik_stats[ik_index].rollback_breaks += 1;
439                    ik_rotations.copy_from_slice(&best_ik_rotations);
440                    self.apply_ik_link_rotations(&links, &base_rotations, &ik_rotations);
441                    if let Some(start_position) = self.min_link_eval_order_position(&links) {
442                        self.update_world_matrices_from_eval_order_position(start_position);
443                    }
444                    broke_early = true;
445                    break;
446                }
447            }
448            self.ik_stats[ik_index].final_distance_sum += f64::from(final_distance);
449            self.ik_stats[ik_index].final_distance_max = self.ik_stats[ik_index]
450                .final_distance_max
451                .max(final_distance);
452            if !broke_early {
453                self.ik_stats[ik_index].max_iteration_exhaustions += 1;
454                self.ik_stats[ik_index].exhausted_final_distance_sum += f64::from(final_distance);
455                self.ik_stats[ik_index].exhausted_final_distance_max = self.ik_stats[ik_index]
456                    .exhausted_final_distance_max
457                    .max(final_distance);
458            }
459
460            // Apply final best effective rotations
461            self.apply_ik_link_rotations(&links, &base_rotations, &best_ik_rotations);
462            if let Some(start_position) = self.min_link_eval_order_position(&links) {
463                self.update_world_matrices_from_eval_order_position(start_position);
464            }
465        } // close for-ik_index
466
467        self.ik_scratch.links = links;
468        self.ik_scratch.base_rotations = base_rotations;
469        self.ik_scratch.ik_rotations = ik_rotations;
470        self.ik_scratch.best_ik_rotations = best_ik_rotations;
471        self.ik_scratch.chain_states = chain_states;
472    }
473
474    fn apply_ik_link_rotations(
475        &mut self,
476        links: &[crate::IkLink],
477        base_rotations: &[Quat],
478        ik_rotations: &[Quat],
479    ) {
480        for (i, link) in links.iter().enumerate() {
481            let effective = (ik_rotations[i] * base_rotations[i]).normalize();
482            self.pose.set_local_rotation(link.bone, effective);
483        }
484    }
485
486    pub fn evaluate_rest_pose(&mut self) {
487        self.pose.reset_local_pose();
488        self.evaluate_current_pose();
489    }
490
491    pub fn evaluate_clip_frame(&mut self, clip: &AnimationClip, frame: f32) {
492        clip.apply_to_pose(frame, &mut self.pose);
493        self.expand_morphs();
494        self.evaluate_current_pose();
495    }
496
497    pub fn evaluate_clip_frame_with_ik_options(
498        &mut self,
499        clip: &AnimationClip,
500        frame: f32,
501        options: IkSolveOptions,
502    ) {
503        clip.apply_to_pose(frame, &mut self.pose);
504        self.expand_morphs();
505        self.evaluate_current_pose_with_ik_options(options);
506    }
507
508    /// Evaluate a clip frame but stop before solving IK. Applies the clip to
509    /// the pose, expands morphs, and updates world matrices - the same setup
510    /// as [`Self::evaluate_clip_frame`] but without calling `solve_enabled_ik`.
511    /// Useful for diagnostics that need to inspect pre-IK runtime state.
512    pub fn evaluate_clip_frame_without_ik(&mut self, clip: &AnimationClip, frame: f32) {
513        clip.apply_to_pose(frame, &mut self.pose);
514        self.expand_morphs();
515        self.update_world_matrices();
516    }
517
518    /// Expand group morphs and apply bone morph offsets.
519    ///
520    /// Called automatically from [`Self::evaluate_clip_frame`]. Exposed publicly so
521    /// that hosts manually driving [`PoseArena`] can trigger morph expansion
522    /// before calling [`Self::evaluate_current_pose`].
523    pub fn expand_morphs(&mut self) {
524        self.expand_group_morphs();
525        self.apply_bone_morphs();
526    }
527
528    /// Pass 1: expand all group morph weights (updates morph_weights in-place).
529    /// Group morph children may appear before or after their parents in PMX, so
530    /// expansion follows the graph recursively using the model-validated
531    /// cycle-free group morph spans.
532    fn expand_group_morphs(&mut self) {
533        let spans = self.model.group_morph_spans();
534        let offsets = self.model.group_morph_offsets();
535        if spans.is_empty() || offsets.is_empty() {
536            return;
537        }
538        let mc = self.model.morph_count() as usize;
539        self.morph_scratch.expanded_weights.clear();
540        self.morph_scratch
541            .expanded_weights
542            .extend_from_slice(&self.pose.morph_weights()[..mc]);
543
544        for (morph_idx, &w) in self.pose.morph_weights()[..mc].iter().enumerate() {
545            if w == 0.0 {
546                continue;
547            }
548            expand_group_morph_weight(
549                morph_idx,
550                w,
551                spans,
552                offsets,
553                &mut self.morph_scratch.expanded_weights,
554            );
555        }
556        for (i, &w) in self.morph_scratch.expanded_weights.iter().enumerate() {
557            self.pose.set_morph_weight(MorphIndex(i as u32), w);
558        }
559    }
560
561    /// Pass 2: apply bone morph offsets using the final (expanded) morph
562    /// weights.
563    fn apply_bone_morphs(&mut self) {
564        let spans = self.model.bone_morph_spans();
565        let offsets = self.model.bone_morph_offsets();
566        if spans.is_empty() || offsets.is_empty() {
567            return;
568        }
569        for (morph_idx, span) in spans.iter().enumerate() {
570            let weight = self.pose.morph_weight(MorphIndex(morph_idx as u32));
571            if weight == 0.0 {
572                continue;
573            }
574            for i in span.start..span.start + span.count {
575                let off = &offsets[i as usize];
576                let pos = self.pose.local_position_offset(off.target_bone);
577                self.pose
578                    .set_local_position_offset(off.target_bone, pos + off.position_offset * weight);
579                let rot = self.pose.local_rotation(off.target_bone);
580                let scaled = Quat::IDENTITY.slerp(off.rotation_offset, weight);
581                self.pose
582                    .set_local_rotation(off.target_bone, (rot * scaled).normalize());
583            }
584        }
585    }
586
587    #[inline]
588    pub fn world_matrices(&self) -> &[Mat4] {
589        self.pose.world_matrices()
590    }
591
592    #[cfg(test)]
593    fn reset_world_matrix_bone_update_count(&mut self) {
594        self.world_matrix_bone_update_count = 0;
595    }
596
597    #[cfg(test)]
598    fn world_matrix_bone_update_count(&self) -> usize {
599        self.world_matrix_bone_update_count
600    }
601
602    #[inline]
603    pub fn skinning_matrices(&self) -> &[Mat4] {
604        self.pose.skinning_matrices()
605    }
606
607    #[inline]
608    pub fn morph_weights(&self) -> &[f32] {
609        self.pose.morph_weights()
610    }
611
612    pub fn reset_ik_runtime_stats(&mut self) {
613        for stats in &mut self.ik_stats {
614            stats.reset();
615        }
616    }
617
618    pub fn ik_runtime_stats(&self) -> &[IkSolverRuntimeStats] {
619        &self.ik_stats
620    }
621
622    #[inline]
623    pub fn ik_enabled(&self) -> &[u8] {
624        self.pose.ik_enabled()
625    }
626}
627
628fn expand_group_morph_weight(
629    morph_idx: usize,
630    weight: f32,
631    spans: &[crate::MorphOffsetSpan],
632    offsets: &[crate::GroupMorphOffset],
633    expanded_weights: &mut [f32],
634) {
635    let span = spans[morph_idx];
636    for i in span.start..span.start + span.count {
637        let off = &offsets[i as usize];
638        let child = off.child_morph.as_usize();
639        let contribution = weight * off.ratio;
640        expanded_weights[child] += contribution;
641        if spans[child].count > 0 {
642            expand_group_morph_weight(child, contribution, spans, offsets, expanded_weights);
643        }
644    }
645}
646
647fn translation(matrix: Mat4) -> Vec3A {
648    Vec3A::from_vec4(matrix.w_axis)
649}
650
651fn rotation(matrix: Mat4) -> Quat {
652    matrix.to_scale_rotation_translation().1
653}
654
655fn constrain_rotation_to_axis(rotation: Quat, axis: Vec3A) -> Quat {
656    let axis = axis.normalize();
657    let vector = Vec3A::new(rotation.x, rotation.y, rotation.z);
658    let projected = axis * vector.dot(axis);
659    let twist = Quat::from_xyzw(projected.x, projected.y, projected.z, rotation.w);
660    if twist.length_squared() <= f32::EPSILON {
661        Quat::IDENTITY
662    } else {
663        twist.normalize()
664    }
665}
666
667#[derive(Debug)]
668struct ChainLinkState {
669    previous_euler: [f32; 3],
670    plane_mode_angle: f32,
671}
672
673fn get_single_axis_limit(limit: Option<crate::IkAngleLimit>) -> Option<usize> {
674    let limit = limit?;
675    let has = [
676        limit.min.x != 0.0 || limit.max.x != 0.0,
677        limit.min.y != 0.0 || limit.max.y != 0.0,
678        limit.min.z != 0.0 || limit.max.z != 0.0,
679    ];
680    if has[0]
681        && limit.min.y == 0.0
682        && limit.max.y == 0.0
683        && limit.min.z == 0.0
684        && limit.max.z == 0.0
685    {
686        return Some(0);
687    }
688    if has[1]
689        && limit.min.x == 0.0
690        && limit.max.x == 0.0
691        && limit.min.z == 0.0
692        && limit.max.z == 0.0
693    {
694        return Some(1);
695    }
696    if has[2]
697        && limit.min.x == 0.0
698        && limit.max.x == 0.0
699        && limit.min.y == 0.0
700        && limit.max.y == 0.0
701    {
702        return Some(2);
703    }
704    None
705}
706
707fn quat_to_rotation_mat3(rotation: Quat) -> [f32; 9] {
708    let [x, y, z, w] = rotation.normalize().to_array();
709    let x2 = x + x;
710    let y2 = y + y;
711    let z2 = z + z;
712    let xx = x * x2;
713    let xy = x * y2;
714    let xz = x * z2;
715    let yy = y * y2;
716    let yz = y * z2;
717    let zz = z * z2;
718    let wx = w * x2;
719    let wy = w * y2;
720    let wz = w * z2;
721    [
722        1.0 - (yy + zz),
723        xy + wz,
724        xz - wy,
725        xy - wz,
726        1.0 - (xx + zz),
727        yz + wx,
728        xz + wy,
729        yz - wx,
730        1.0 - (xx + yy),
731    ]
732}
733
734fn decompose_euler_xyz(mat: &[f32; 9], before: &[f32; 3]) -> [f32; 3] {
735    let sy = -mat[2];
736    let mut result: [f32; 3];
737    if 1.0 - sy.abs() < 1e-6 {
738        let y = sy.asin();
739        let sx = before[0].sin();
740        let sz = before[2].sin();
741        if sx.abs() < sz.abs() {
742            let cx = before[0].cos();
743            result = if cx > 0.0 {
744                [0.0, y, (-mat[3]).asin()]
745            } else {
746                [std::f32::consts::PI, y, mat[3].asin()]
747            };
748        } else {
749            let cz = before[2].cos();
750            result = if cz > 0.0 {
751                [(-mat[7]).asin(), y, 0.0]
752            } else {
753                [mat[7].asin(), y, std::f32::consts::PI]
754            };
755        }
756    } else {
757        result = [mat[5].atan2(mat[8]), (-mat[2]).asin(), mat[1].atan2(mat[0])];
758    }
759
760    let pi = std::f32::consts::PI;
761    let candidates: [[f32; 3]; 8] = [
762        [result[0] + pi, pi - result[1], result[2] + pi],
763        [result[0] + pi, pi - result[1], result[2] - pi],
764        [result[0] + pi, -pi - result[1], result[2] + pi],
765        [result[0] + pi, -pi - result[1], result[2] - pi],
766        [result[0] - pi, pi - result[1], result[2] + pi],
767        [result[0] - pi, pi - result[1], result[2] - pi],
768        [result[0] - pi, -pi - result[1], result[2] + pi],
769        [result[0] - pi, -pi - result[1], result[2] - pi],
770    ];
771    let mut min_error = diff_angle(result[0], before[0]).abs()
772        + diff_angle(result[1], before[1]).abs()
773        + diff_angle(result[2], before[2]).abs();
774    for candidate in &candidates {
775        let error = diff_angle(candidate[0], before[0]).abs()
776            + diff_angle(candidate[1], before[1]).abs()
777            + diff_angle(candidate[2], before[2]).abs();
778        if error < min_error {
779            min_error = error;
780            result = *candidate;
781        }
782    }
783    result
784}
785
786fn diff_angle(a: f32, b: f32) -> f32 {
787    let diff = normalize_angle(a) - normalize_angle(b);
788    if diff > std::f32::consts::PI {
789        diff - std::f32::consts::TAU
790    } else if diff < -std::f32::consts::PI {
791        diff + std::f32::consts::TAU
792    } else {
793        diff
794    }
795}
796
797fn normalize_angle(angle: f32) -> f32 {
798    let mut result = angle;
799    while result >= std::f32::consts::TAU {
800        result -= std::f32::consts::TAU;
801    }
802    while result < 0.0 {
803        result += std::f32::consts::TAU;
804    }
805    result
806}
807
808fn euler_xyz_to_quat(euler: &[f32; 3]) -> Quat {
809    let [x, y, z] = *euler;
810    let c1 = (x / 2.0).cos();
811    let c2 = (y / 2.0).cos();
812    let c3 = (z / 2.0).cos();
813    let s1 = (x / 2.0).sin();
814    let s2 = (y / 2.0).sin();
815    let s3 = (z / 2.0).sin();
816    Quat::from_xyzw(
817        s1 * c2 * c3 + c1 * s2 * s3,
818        c1 * s2 * c3 - s1 * c2 * s3,
819        c1 * c2 * s3 + s1 * s2 * c3,
820        c1 * c2 * c3 - s1 * s2 * s3,
821    )
822}
823
824struct LimitedAxesLinkStepInput<'a> {
825    local_effector: &'a Vec3A,
826    local_target: &'a Vec3A,
827    link_index: usize,
828    base_rotations: &'a [Quat],
829    ik_rotations: &'a mut [Quat],
830    chain_states: &'a mut [ChainLinkState],
831    limits: crate::IkAngleLimit,
832    limit_angle: f32,
833}
834
835fn solve_limited_axes_link_step(input: LimitedAxesLinkStepInput<'_>) {
836    let state = &mut input.chain_states[input.link_index];
837    let base = input.base_rotations[input.link_index];
838    let current = (input.ik_rotations[input.link_index] * base).normalize();
839    let current_mat = quat_to_rotation_mat3(current);
840    let mut total_euler = decompose_euler_xyz(&current_mat, &state.previous_euler);
841    let mut working_effector = *input.local_effector;
842    let target = input.local_target.normalize();
843
844    for axis_index in [2usize, 1, 0] {
845        let (lower, upper) = limit_axis_bounds(input.limits, axis_index);
846        if lower == 0.0 && upper == 0.0 {
847            let next = total_euler[axis_index].clamp(lower, upper);
848            let applied = next - total_euler[axis_index];
849            total_euler[axis_index] = next;
850            if applied.abs() > 0.0 {
851                working_effector = Quat::from_axis_angle(axis_vec(axis_index).into(), applied)
852                    .mul_vec3a(working_effector);
853            }
854            continue;
855        }
856
857        let axis = axis_vec(axis_index);
858        let signed_angle = signed_projected_angle(working_effector, target, axis);
859        if signed_angle.abs() <= 1.0e-6 {
860            continue;
861        }
862        let step = if input.limit_angle > 0.0 {
863            signed_angle.clamp(-input.limit_angle, input.limit_angle)
864        } else {
865            signed_angle
866        };
867        let next = (total_euler[axis_index] + step).clamp(lower, upper);
868        let applied = next - total_euler[axis_index];
869        total_euler[axis_index] = next;
870        if applied.abs() > 0.0 {
871            working_effector =
872                Quat::from_axis_angle(axis.into(), applied).mul_vec3a(working_effector);
873        }
874    }
875
876    state.previous_euler = total_euler;
877    let chain_rotation = euler_xyz_to_quat(&total_euler).normalize();
878    input.ik_rotations[input.link_index] = (chain_rotation * base.inverse()).normalize();
879}
880
881fn limit_axis_bounds(limits: crate::IkAngleLimit, axis_index: usize) -> (f32, f32) {
882    match axis_index {
883        0 => (limits.min.x, limits.max.x),
884        1 => (limits.min.y, limits.max.y),
885        _ => (limits.min.z, limits.max.z),
886    }
887}
888
889fn axis_vec(axis_index: usize) -> Vec3A {
890    match axis_index {
891        0 => Vec3A::new(1.0, 0.0, 0.0),
892        1 => Vec3A::new(0.0, 1.0, 0.0),
893        _ => Vec3A::new(0.0, 0.0, 1.0),
894    }
895}
896
897fn signed_projected_angle(from: Vec3A, to: Vec3A, axis: Vec3A) -> f32 {
898    let projected_from = from - axis * from.dot(axis);
899    let projected_to = to - axis * to.dot(axis);
900    if projected_from.length_squared() <= f32::EPSILON
901        || projected_to.length_squared() <= f32::EPSILON
902    {
903        return 0.0;
904    }
905    let from_n = projected_from.normalize();
906    let to_n = projected_to.normalize();
907    let dot = from_n.dot(to_n).clamp(-1.0, 1.0);
908    let angle = dot.acos();
909    let sign = axis.dot(from_n.cross(to_n)).signum();
910    angle * if sign == 0.0 { 1.0 } else { sign }
911}
912
913struct PlaneLinkStepInput<'a> {
914    local_effector: &'a Vec3A,
915    local_target: &'a Vec3A,
916    link_index: usize,
917    base_rotations: &'a [Quat],
918    ik_rotations: &'a mut [Quat],
919    chain_states: &'a mut [ChainLinkState],
920    axis_index: usize,
921    limits: crate::IkAngleLimit,
922    iteration: usize,
923    limit_angle: f32,
924}
925
926fn solve_plane_link_step(input: PlaneLinkStepInput<'_>) {
927    let rotate_axis = match input.axis_index {
928        0 => Vec3A::new(1.0, 0.0, 0.0),
929        1 => Vec3A::new(0.0, 1.0, 0.0),
930        _ => Vec3A::new(0.0, 0.0, 1.0),
931    };
932    let local_eff_n = input.local_effector.normalize();
933    let local_tgt_n = input.local_target.normalize();
934
935    let dot = local_eff_n.dot(local_tgt_n).clamp(-1.0, 1.0);
936    let raw_angle = dot.acos();
937    let capped_angle = if input.limit_angle > 0.0 {
938        raw_angle.min(input.limit_angle)
939    } else {
940        raw_angle
941    };
942
943    let target_vec1 =
944        Quat::from_axis_angle(rotate_axis.into(), capped_angle).mul_vec3a(local_eff_n);
945    let target_vec2 =
946        Quat::from_axis_angle(rotate_axis.into(), -capped_angle).mul_vec3a(local_eff_n);
947    let signed_angle = if target_vec1.dot(local_tgt_n) > target_vec2.dot(local_tgt_n) {
948        capped_angle
949    } else {
950        -capped_angle
951    };
952
953    let state = &mut input.chain_states[input.link_index];
954    let mut next_angle = state.plane_mode_angle + signed_angle;
955    let (lower, upper) = match input.axis_index {
956        0 => (input.limits.min.x, input.limits.max.x),
957        1 => (input.limits.min.y, input.limits.max.y),
958        _ => (input.limits.min.z, input.limits.max.z),
959    };
960    let base = input.base_rotations[input.link_index];
961
962    if input.iteration == 0 && (next_angle < lower || next_angle > upper) {
963        if -next_angle > lower && -next_angle < upper {
964            next_angle = -next_angle;
965        } else {
966            let half = (lower + upper) * 0.5;
967            if (half - next_angle).abs() > (half + next_angle).abs() {
968                next_angle = -next_angle;
969            }
970        }
971    }
972
973    state.plane_mode_angle = next_angle.clamp(lower, upper);
974    let chain_rotation = Quat::from_axis_angle(rotate_axis.into(), state.plane_mode_angle);
975    input.ik_rotations[input.link_index] = (chain_rotation * base.inverse()).normalize();
976}
977
978#[cfg(test)]
979mod tests {
980    use std::sync::Arc;
981
982    use glam::{Quat, Vec3A};
983
984    use crate::{
985        AnimationClip, AppendTransformInit, BoneAnimationBinding, BoneIndex, BoneInit,
986        IkAngleLimit, IkLinkInit, IkSolverInit, ModelArena, MovableBoneKeyframe, MovableBoneTrack,
987        RuntimeInstance,
988    };
989
990    fn translation(matrix: glam::Mat4) -> Vec3A {
991        Vec3A::from_vec4(matrix.w_axis)
992    }
993
994    fn assert_vec3a_near(actual: Vec3A, expected: Vec3A) {
995        let delta = (actual - expected).abs();
996        assert!(
997            delta.x < 1.0e-5 && delta.y < 1.0e-5 && delta.z < 1.0e-5,
998            "actual={actual:?} expected={expected:?} delta={delta:?}"
999        );
1000    }
1001
1002    #[test]
1003    fn evaluates_rest_pose_world_matrices() {
1004        let model = Arc::new(
1005            ModelArena::new(vec![
1006                BoneInit::new(None, Vec3A::new(1.0, 0.0, 0.0)),
1007                BoneInit::new(Some(BoneIndex(0)), Vec3A::new(0.0, 2.0, 0.0)),
1008            ])
1009            .unwrap(),
1010        );
1011        let mut runtime = RuntimeInstance::new(model);
1012
1013        runtime.evaluate_rest_pose();
1014
1015        assert_vec3a_near(
1016            translation(runtime.world_matrices()[0]),
1017            Vec3A::new(1.0, 0.0, 0.0),
1018        );
1019        assert_vec3a_near(
1020            translation(runtime.world_matrices()[1]),
1021            Vec3A::new(1.0, 2.0, 0.0),
1022        );
1023    }
1024
1025    #[test]
1026    fn evaluates_current_pose_with_parent_rotation() {
1027        let model = Arc::new(
1028            ModelArena::new(vec![
1029                BoneInit::new(None, Vec3A::new(1.0, 0.0, 0.0)),
1030                BoneInit::new(Some(BoneIndex(0)), Vec3A::new(0.0, 2.0, 0.0)),
1031            ])
1032            .unwrap(),
1033        );
1034        let mut runtime = RuntimeInstance::new(model);
1035
1036        runtime.pose_mut().set_local_rotation(
1037            BoneIndex(0),
1038            Quat::from_rotation_z(std::f32::consts::FRAC_PI_2),
1039        );
1040        runtime.evaluate_current_pose();
1041
1042        assert_vec3a_near(
1043            translation(runtime.world_matrices()[1]),
1044            Vec3A::new(-1.0, 0.0, 0.0),
1045        );
1046    }
1047
1048    #[test]
1049    fn fixed_axis_bone_rotation_keeps_only_axis_twist() {
1050        let model = Arc::new(
1051            ModelArena::new(vec![
1052                BoneInit::new(None, Vec3A::ZERO).with_fixed_axis(Vec3A::Y),
1053                BoneInit::new(Some(BoneIndex(0)), Vec3A::X),
1054            ])
1055            .unwrap(),
1056        );
1057        let mut runtime = RuntimeInstance::new(model);
1058
1059        runtime.pose_mut().set_local_rotation(
1060            BoneIndex(0),
1061            (Quat::from_rotation_y(std::f32::consts::FRAC_PI_2)
1062                * Quat::from_rotation_x(std::f32::consts::FRAC_PI_2))
1063            .normalize(),
1064        );
1065        runtime.evaluate_current_pose();
1066
1067        assert_vec3a_near(
1068            translation(runtime.world_matrices()[1]),
1069            Vec3A::new(0.0, 0.0, -1.0),
1070        );
1071    }
1072
1073    #[test]
1074    fn evaluates_current_pose_with_local_position_offset() {
1075        let model = Arc::new(
1076            ModelArena::new(vec![
1077                BoneInit::new(None, Vec3A::new(1.0, 0.0, 0.0)),
1078                BoneInit::new(Some(BoneIndex(0)), Vec3A::new(0.0, 2.0, 0.0)),
1079            ])
1080            .unwrap(),
1081        );
1082        let mut runtime = RuntimeInstance::new(model);
1083
1084        runtime
1085            .pose_mut()
1086            .set_local_position_offset(BoneIndex(1), Vec3A::new(0.0, 0.0, 3.0));
1087        runtime.evaluate_current_pose();
1088
1089        assert_vec3a_near(
1090            translation(runtime.world_matrices()[1]),
1091            Vec3A::new(1.0, 2.0, 3.0),
1092        );
1093    }
1094
1095    #[test]
1096    fn evaluates_clip_frame_into_world_matrices() {
1097        let model = Arc::new(
1098            ModelArena::new(vec![
1099                BoneInit::new(None, Vec3A::new(1.0, 0.0, 0.0)),
1100                BoneInit::new(Some(BoneIndex(0)), Vec3A::new(0.0, 2.0, 0.0)),
1101            ])
1102            .unwrap(),
1103        );
1104        let clip = AnimationClip::new(vec![BoneAnimationBinding {
1105            bone: BoneIndex(1),
1106            track: MovableBoneTrack::from_keyframes(vec![
1107                MovableBoneKeyframe::new(0, Vec3A::ZERO, Quat::IDENTITY),
1108                MovableBoneKeyframe::new(10, Vec3A::new(0.0, 0.0, 4.0), Quat::IDENTITY),
1109            ]),
1110        }]);
1111        let mut runtime = RuntimeInstance::new(model);
1112
1113        runtime.evaluate_clip_frame(&clip, 5.0);
1114
1115        assert_vec3a_near(
1116            translation(runtime.world_matrices()[1]),
1117            Vec3A::new(1.0, 2.0, 2.0),
1118        );
1119    }
1120
1121    #[test]
1122    fn applies_append_rotation_before_world_matrix_output() {
1123        let model = Arc::new(
1124            ModelArena::new_full(
1125                vec![
1126                    BoneInit::new(None, Vec3A::ZERO),
1127                    BoneInit::new(None, Vec3A::ZERO),
1128                    BoneInit::new(Some(BoneIndex(1)), Vec3A::new(1.0, 0.0, 0.0)),
1129                ],
1130                Vec::new(),
1131                vec![AppendTransformInit::new(BoneIndex(1), BoneIndex(0), 1.0).with_rotation()],
1132            )
1133            .unwrap(),
1134        );
1135        let mut runtime = RuntimeInstance::new(model);
1136
1137        runtime.pose_mut().set_local_rotation(
1138            BoneIndex(0),
1139            Quat::from_rotation_z(std::f32::consts::FRAC_PI_2),
1140        );
1141        runtime.evaluate_current_pose();
1142
1143        assert_vec3a_near(
1144            translation(runtime.world_matrices()[2]),
1145            Vec3A::new(0.0, 1.0, 0.0),
1146        );
1147    }
1148
1149    #[test]
1150    fn applies_append_translation_before_world_matrix_output() {
1151        let model = Arc::new(
1152            ModelArena::new_full(
1153                vec![
1154                    BoneInit::new(None, Vec3A::ZERO),
1155                    BoneInit::new(None, Vec3A::new(0.0, 1.0, 0.0)),
1156                ],
1157                Vec::new(),
1158                vec![AppendTransformInit::new(BoneIndex(1), BoneIndex(0), 0.5).with_translation()],
1159            )
1160            .unwrap(),
1161        );
1162        let mut runtime = RuntimeInstance::new(model);
1163
1164        runtime
1165            .pose_mut()
1166            .set_local_position_offset(BoneIndex(0), Vec3A::new(2.0, 0.0, 0.0));
1167        runtime.evaluate_current_pose();
1168
1169        assert_vec3a_near(
1170            translation(runtime.world_matrices()[1]),
1171            Vec3A::new(1.0, 1.0, 0.0),
1172        );
1173    }
1174
1175    #[test]
1176    fn initializes_ik_enabled_from_model_solvers() {
1177        let model = Arc::new(
1178            ModelArena::new_with_ik(
1179                vec![
1180                    BoneInit::new(None, Vec3A::ZERO),
1181                    BoneInit::new(Some(BoneIndex(0)), Vec3A::ZERO),
1182                ],
1183                vec![IkSolverInit::new(
1184                    BoneIndex(1),
1185                    BoneIndex(0),
1186                    vec![IkLinkInit::new(BoneIndex(0))],
1187                )],
1188            )
1189            .unwrap(),
1190        );
1191
1192        let runtime = RuntimeInstance::new(model);
1193
1194        assert_eq!(runtime.ik_enabled(), &[1]);
1195    }
1196
1197    #[test]
1198    fn solves_one_link_ik_toward_controller_bone() {
1199        let model = Arc::new(
1200            ModelArena::new_with_ik(
1201                vec![
1202                    BoneInit::new(None, Vec3A::ZERO),
1203                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(1.0, 0.0, 0.0)),
1204                    BoneInit::new(None, Vec3A::new(0.0, 1.0, 0.0)),
1205                ],
1206                vec![IkSolverInit {
1207                    ik_bone: BoneIndex(2),
1208                    target_bone: BoneIndex(1),
1209                    links: vec![IkLinkInit::new(BoneIndex(0))],
1210                    iteration_count: 1,
1211                    limit_angle: 0.0,
1212                }],
1213            )
1214            .unwrap(),
1215        );
1216        let mut runtime = RuntimeInstance::new(model);
1217
1218        runtime.evaluate_current_pose();
1219
1220        assert_vec3a_near(
1221            translation(runtime.world_matrices()[1]),
1222            Vec3A::new(0.0, 1.0, 0.0),
1223        );
1224    }
1225
1226    #[test]
1227    fn skips_disabled_ik_solver() {
1228        let model = Arc::new(
1229            ModelArena::new_with_ik(
1230                vec![
1231                    BoneInit::new(None, Vec3A::ZERO),
1232                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(1.0, 0.0, 0.0)),
1233                    BoneInit::new(None, Vec3A::new(0.0, 1.0, 0.0)),
1234                ],
1235                vec![IkSolverInit {
1236                    ik_bone: BoneIndex(2),
1237                    target_bone: BoneIndex(1),
1238                    links: vec![IkLinkInit::new(BoneIndex(0))],
1239                    iteration_count: 1,
1240                    limit_angle: 0.0,
1241                }],
1242            )
1243            .unwrap(),
1244        );
1245        let mut runtime = RuntimeInstance::new(model);
1246
1247        runtime.pose_mut().set_ik_enabled(0, false);
1248        runtime.evaluate_current_pose();
1249
1250        assert_vec3a_near(
1251            translation(runtime.world_matrices()[1]),
1252            Vec3A::new(1.0, 0.0, 0.0),
1253        );
1254    }
1255
1256    #[test]
1257    fn solves_two_link_ik_chain_toward_controller_bone() {
1258        let model = Arc::new(
1259            ModelArena::new_with_ik(
1260                vec![
1261                    BoneInit::new(None, Vec3A::ZERO),
1262                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(1.0, 0.0, 0.0)),
1263                    BoneInit::new(Some(BoneIndex(1)), Vec3A::new(1.0, 0.0, 0.0)),
1264                    BoneInit::new(None, Vec3A::new(1.0, 1.0, 0.0)),
1265                ],
1266                vec![IkSolverInit {
1267                    ik_bone: BoneIndex(3),
1268                    target_bone: BoneIndex(2),
1269                    links: vec![IkLinkInit::new(BoneIndex(1)), IkLinkInit::new(BoneIndex(0))],
1270                    iteration_count: 4,
1271                    limit_angle: 0.0,
1272                }],
1273            )
1274            .unwrap(),
1275        );
1276        let mut runtime = RuntimeInstance::new(model);
1277
1278        runtime.evaluate_current_pose();
1279
1280        assert_vec3a_near(
1281            translation(runtime.world_matrices()[2]),
1282            Vec3A::new(1.0, 1.0, 0.0),
1283        );
1284    }
1285
1286    #[test]
1287    fn ik_updates_only_affected_eval_suffix_for_late_chain() {
1288        let unrelated_count = 96usize;
1289        let chain_root = BoneIndex(unrelated_count as u32);
1290        let chain_mid = BoneIndex(unrelated_count as u32 + 1);
1291        let chain_tip = BoneIndex(unrelated_count as u32 + 2);
1292        let controller = BoneIndex(unrelated_count as u32 + 3);
1293
1294        let mut bones = Vec::new();
1295        for i in 0..unrelated_count {
1296            bones.push(BoneInit::new(None, Vec3A::new(i as f32 * 10.0, -10.0, 0.0)));
1297        }
1298        bones.push(BoneInit::new(None, Vec3A::ZERO));
1299        bones.push(BoneInit::new(Some(chain_root), Vec3A::new(1.0, 0.0, 0.0)));
1300        bones.push(BoneInit::new(Some(chain_mid), Vec3A::new(1.0, 0.0, 0.0)));
1301        bones.push(BoneInit::new(None, Vec3A::new(1.0, 1.0, 0.0)));
1302
1303        let model = Arc::new(
1304            ModelArena::new_with_ik(
1305                bones,
1306                vec![IkSolverInit {
1307                    ik_bone: controller,
1308                    target_bone: chain_tip,
1309                    links: vec![IkLinkInit::new(chain_mid), IkLinkInit::new(chain_root)],
1310                    iteration_count: 4,
1311                    limit_angle: 0.0,
1312                }],
1313            )
1314            .unwrap(),
1315        );
1316        let mut runtime = RuntimeInstance::new(model);
1317
1318        runtime.reset_world_matrix_bone_update_count();
1319        runtime.evaluate_current_pose();
1320
1321        assert_vec3a_near(
1322            translation(runtime.world_matrices()[chain_tip.as_usize()]),
1323            Vec3A::new(1.0, 1.0, 0.0),
1324        );
1325        assert!(
1326            runtime.world_matrix_bone_update_count() < 250,
1327            "IK should not recompute unrelated prefix bones repeatedly; updated {} bones",
1328            runtime.world_matrix_bone_update_count()
1329        );
1330    }
1331
1332    #[test]
1333    fn clamps_ik_rotation_by_solver_limit_angle() {
1334        let model = Arc::new(
1335            ModelArena::new_with_ik(
1336                vec![
1337                    BoneInit::new(None, Vec3A::ZERO),
1338                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(1.0, 0.0, 0.0)),
1339                    BoneInit::new(None, Vec3A::new(0.0, 1.0, 0.0)),
1340                ],
1341                vec![IkSolverInit {
1342                    ik_bone: BoneIndex(2),
1343                    target_bone: BoneIndex(1),
1344                    links: vec![IkLinkInit::new(BoneIndex(0))],
1345                    iteration_count: 1,
1346                    limit_angle: std::f32::consts::FRAC_PI_4,
1347                }],
1348            )
1349            .unwrap(),
1350        );
1351        let mut runtime = RuntimeInstance::new(model);
1352
1353        runtime.evaluate_current_pose();
1354
1355        let expected = Vec3A::new(
1356            std::f32::consts::FRAC_1_SQRT_2,
1357            std::f32::consts::FRAC_1_SQRT_2,
1358            0.0,
1359        );
1360        assert_vec3a_near(translation(runtime.world_matrices()[1]), expected);
1361    }
1362
1363    #[test]
1364    fn applies_constant_limit_angle_per_iteration() {
1365        let model = Arc::new(
1366            ModelArena::new_with_ik(
1367                vec![
1368                    BoneInit::new(None, Vec3A::ZERO),
1369                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(1.0, 0.0, 0.0)),
1370                    BoneInit::new(None, Vec3A::new(0.0, 1.0, 0.0)),
1371                ],
1372                vec![IkSolverInit {
1373                    ik_bone: BoneIndex(2),
1374                    target_bone: BoneIndex(1),
1375                    links: vec![IkLinkInit::new(BoneIndex(1)), IkLinkInit::new(BoneIndex(0))],
1376                    iteration_count: 1,
1377                    limit_angle: std::f32::consts::FRAC_PI_4,
1378                }],
1379            )
1380            .unwrap(),
1381        );
1382        let mut runtime = RuntimeInstance::new(model);
1383
1384        runtime.evaluate_current_pose();
1385
1386        // With constant limit_angle = PI/4 (not scaled by link_index), only the root
1387        // (link 1, bone 0) rotates at most PI/4. The effector bone is skipped.
1388        // The child bone ends up at (cos(PI/4)*1, sin(PI/4)*1, 0)
1389        let expected = Vec3A::new(
1390            std::f32::consts::FRAC_1_SQRT_2,
1391            std::f32::consts::FRAC_1_SQRT_2,
1392            0.0,
1393        );
1394        assert_vec3a_near(translation(runtime.world_matrices()[1]), expected);
1395    }
1396
1397    #[test]
1398    fn clip_frame_produces_deterministic_world_translations() {
1399        let model = Arc::new(
1400            ModelArena::new(vec![
1401                BoneInit::new(None, Vec3A::new(1.0, 0.0, 0.0)),
1402                BoneInit::new(Some(BoneIndex(0)), Vec3A::new(0.0, 2.0, 0.0)),
1403            ])
1404            .unwrap(),
1405        );
1406        let clip = AnimationClip::new(vec![BoneAnimationBinding {
1407            bone: BoneIndex(1),
1408            track: MovableBoneTrack::from_keyframes(vec![
1409                MovableBoneKeyframe::new(0, Vec3A::ZERO, Quat::IDENTITY),
1410                MovableBoneKeyframe::new(10, Vec3A::new(0.0, 0.0, 4.0), Quat::IDENTITY),
1411            ]),
1412        }]);
1413        let mut runtime = RuntimeInstance::new(model);
1414
1415        runtime.evaluate_clip_frame(&clip, 5.0);
1416
1417        let matrices = runtime.world_matrices();
1418        assert_eq!(matrices.len(), 2);
1419        assert_vec3a_near(translation(matrices[0]), Vec3A::new(1.0, 0.0, 0.0));
1420        assert_vec3a_near(translation(matrices[1]), Vec3A::new(1.0, 2.0, 2.0));
1421    }
1422
1423    #[test]
1424    fn evaluate_clip_frame_without_ik_leaves_ik_unsolved() {
1425        let model = Arc::new(
1426            ModelArena::new_with_ik(
1427                vec![
1428                    BoneInit::new(None, Vec3A::ZERO),
1429                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(1.0, 0.0, 0.0)),
1430                    BoneInit::new(None, Vec3A::new(0.0, 1.0, 0.0)),
1431                ],
1432                vec![IkSolverInit {
1433                    ik_bone: BoneIndex(2),
1434                    target_bone: BoneIndex(1),
1435                    links: vec![IkLinkInit::new(BoneIndex(0))],
1436                    iteration_count: 1,
1437                    limit_angle: 0.0,
1438                }],
1439            )
1440            .unwrap(),
1441        );
1442        let clip = AnimationClip::new(vec![]);
1443
1444        let mut without_ik = RuntimeInstance::new(Arc::clone(&model));
1445        let mut with_ik = RuntimeInstance::new(model);
1446
1447        without_ik.evaluate_clip_frame_without_ik(&clip, 0.0);
1448        with_ik.evaluate_clip_frame(&clip, 0.0);
1449
1450        // Without IK: effector bone stays at rest position (1, 0, 0)
1451        assert_vec3a_near(
1452            translation(without_ik.world_matrices()[1]),
1453            Vec3A::new(1.0, 0.0, 0.0),
1454        );
1455        // With IK: effector bone rotates toward target at (0, 1, 0)
1456        assert_vec3a_near(
1457            translation(with_ik.world_matrices()[1]),
1458            Vec3A::new(0.0, 1.0, 0.0),
1459        );
1460    }
1461
1462    #[test]
1463    fn ik_options_cap_configured_iterations() {
1464        let model = Arc::new(
1465            ModelArena::new_with_ik(
1466                vec![
1467                    BoneInit::new(None, Vec3A::ZERO),
1468                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(1.0, 0.0, 0.0)),
1469                    BoneInit::new(None, Vec3A::new(0.0, 1.0, 0.0)),
1470                ],
1471                vec![IkSolverInit {
1472                    ik_bone: BoneIndex(2),
1473                    target_bone: BoneIndex(1),
1474                    links: vec![IkLinkInit::new(BoneIndex(0))],
1475                    iteration_count: 100,
1476                    limit_angle: 0.0,
1477                }],
1478            )
1479            .unwrap(),
1480        );
1481        let mut runtime = RuntimeInstance::new(model);
1482
1483        runtime.reset_ik_runtime_stats();
1484        runtime.evaluate_current_pose_with_ik_options(super::IkSolveOptions {
1485            tolerance: 0.0,
1486            max_iterations_cap: Some(5),
1487        });
1488
1489        assert_eq!(runtime.ik_runtime_stats()[0].configured_iterations, 5);
1490    }
1491
1492    // ---- morph expansion tests ----
1493
1494    fn assert_near(actual: f32, expected: f32) {
1495        let delta = (actual - expected).abs();
1496        assert!(
1497            delta < 1.0e-5,
1498            "actual={actual:?} expected={expected:?} delta={delta:?}"
1499        );
1500    }
1501
1502    #[test]
1503    fn bone_morph_position_offset_drives_world_position() {
1504        let model = Arc::new(
1505            ModelArena::new_with_morphs(
1506                vec![
1507                    BoneInit::new(None, Vec3A::ZERO),
1508                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(0.0, 1.0, 0.0)),
1509                ],
1510                Vec::new(),
1511                Vec::new(),
1512                crate::MorphInit {
1513                    morph_count: 1,
1514                    bone_offsets: vec![crate::BoneMorphOffset {
1515                        target_bone: BoneIndex(1),
1516                        position_offset: Vec3A::new(0.0, 0.0, 2.0),
1517                        rotation_offset: Quat::IDENTITY,
1518                    }],
1519                    bone_spans: vec![crate::MorphOffsetSpan { start: 0, count: 1 }],
1520                    group_offsets: vec![],
1521                    group_spans: vec![crate::MorphOffsetSpan::default()],
1522                    ..crate::MorphInit::default()
1523                },
1524            )
1525            .unwrap(),
1526        );
1527        let clip = AnimationClip::new_with_morphs(
1528            Vec::new(),
1529            vec![crate::MorphAnimationBinding {
1530                morph: crate::MorphIndex(0),
1531                track: crate::MorphTrack::from_keyframes(vec![
1532                    crate::MorphKeyframe::new(0, 0.0),
1533                    crate::MorphKeyframe::new(10, 1.0),
1534                ]),
1535            }],
1536        );
1537        let mut runtime = RuntimeInstance::new_with_morph_count(model, 1);
1538
1539        runtime.evaluate_clip_frame(&clip, 5.0);
1540
1541        // weight = 0.5: bone offset = (0,0,2) * 0.5 = (0,0,1)
1542        assert_vec3a_near(
1543            translation(runtime.world_matrices()[1]),
1544            Vec3A::new(0.0, 1.0, 1.0),
1545        );
1546        assert_near(runtime.morph_weights()[0], 0.5);
1547    }
1548
1549    #[test]
1550    fn bone_morph_rotation_offset_affects_child_position() {
1551        let model = Arc::new(
1552            ModelArena::new_with_morphs(
1553                vec![
1554                    BoneInit::new(None, Vec3A::ZERO),
1555                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(1.0, 0.0, 0.0)),
1556                    BoneInit::new(Some(BoneIndex(1)), Vec3A::new(1.0, 0.0, 0.0)),
1557                ],
1558                Vec::new(),
1559                Vec::new(),
1560                crate::MorphInit {
1561                    morph_count: 1,
1562                    bone_offsets: vec![crate::BoneMorphOffset {
1563                        target_bone: BoneIndex(1),
1564                        position_offset: Vec3A::ZERO,
1565                        rotation_offset: Quat::from_rotation_z(std::f32::consts::FRAC_PI_2),
1566                    }],
1567                    bone_spans: vec![crate::MorphOffsetSpan { start: 0, count: 1 }],
1568                    group_offsets: vec![],
1569                    group_spans: vec![crate::MorphOffsetSpan::default()],
1570                    ..crate::MorphInit::default()
1571                },
1572            )
1573            .unwrap(),
1574        );
1575        let clip = AnimationClip::new_with_morphs(
1576            Vec::new(),
1577            vec![crate::MorphAnimationBinding {
1578                morph: crate::MorphIndex(0),
1579                track: crate::MorphTrack::from_keyframes(vec![
1580                    crate::MorphKeyframe::new(0, 0.0),
1581                    crate::MorphKeyframe::new(10, 1.0),
1582                ]),
1583            }],
1584        );
1585        let mut runtime = RuntimeInstance::new_with_morph_count(model, 1);
1586
1587        runtime.evaluate_clip_frame(&clip, 10.0);
1588
1589        // weight = 1.0: bone 1 (rest 1,0,0) rotated Z-90 by morph (position unchanged)
1590        // bone 2 at (1,0,0) relative to bone 1: world = (1,0,0) + (0,1,0)
1591        assert_vec3a_near(
1592            translation(runtime.world_matrices()[2]),
1593            Vec3A::new(1.0, 1.0, 0.0),
1594        );
1595    }
1596
1597    #[test]
1598    fn group_morph_contributes_to_bone_morph_weight() {
1599        // PMX order: child (bone morph) has smaller index than parent (group morph)
1600        // Morph 0 = bone morph, Morph 1 = group morph with MorphIndex(0) as child.
1601        let model = Arc::new(
1602            ModelArena::new_with_morphs(
1603                vec![
1604                    BoneInit::new(None, Vec3A::ZERO),
1605                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(0.0, 1.0, 0.0)),
1606                ],
1607                Vec::new(),
1608                Vec::new(),
1609                crate::MorphInit {
1610                    morph_count: 2,
1611                    bone_offsets: vec![crate::BoneMorphOffset {
1612                        target_bone: BoneIndex(1),
1613                        position_offset: Vec3A::new(0.0, 0.0, 2.0),
1614                        rotation_offset: Quat::IDENTITY,
1615                    }],
1616                    bone_spans: vec![
1617                        crate::MorphOffsetSpan { start: 0, count: 1 },
1618                        crate::MorphOffsetSpan::default(),
1619                    ],
1620                    group_offsets: vec![crate::GroupMorphOffset {
1621                        child_morph: crate::MorphIndex(0),
1622                        ratio: 0.5,
1623                    }],
1624                    group_spans: vec![
1625                        crate::MorphOffsetSpan::default(),
1626                        crate::MorphOffsetSpan { start: 0, count: 1 },
1627                    ],
1628                    ..crate::MorphInit::default()
1629                },
1630            )
1631            .unwrap(),
1632        );
1633        // VMD track only on group morph (index 1), weight = 1.0
1634        let clip = AnimationClip::new_with_morphs(
1635            Vec::new(),
1636            vec![crate::MorphAnimationBinding {
1637                morph: crate::MorphIndex(1),
1638                track: crate::MorphTrack::from_keyframes(vec![
1639                    crate::MorphKeyframe::new(0, 0.0),
1640                    crate::MorphKeyframe::new(10, 1.0),
1641                ]),
1642            }],
1643        );
1644        let mut runtime = RuntimeInstance::new_with_morph_count(model, 2);
1645
1646        runtime.evaluate_clip_frame(&clip, 10.0);
1647
1648        // Group expansion: morph_weights[0] += 1.0 * 0.5 = 0.5
1649        // Bone morph applies: (0,0,2) * 0.5 = (0,0,1)
1650        assert_near(runtime.morph_weights()[0], 0.5);
1651        assert_near(runtime.morph_weights()[1], 1.0);
1652        assert_vec3a_near(
1653            translation(runtime.world_matrices()[1]),
1654            Vec3A::new(0.0, 1.0, 1.0),
1655        );
1656    }
1657
1658    #[test]
1659    fn group_morph_can_reference_later_child_morph() {
1660        let model = Arc::new(
1661            ModelArena::new_with_morphs(
1662                vec![
1663                    BoneInit::new(None, Vec3A::ZERO),
1664                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(0.0, 1.0, 0.0)),
1665                ],
1666                Vec::new(),
1667                Vec::new(),
1668                crate::MorphInit {
1669                    morph_count: 2,
1670                    bone_offsets: vec![crate::BoneMorphOffset {
1671                        target_bone: BoneIndex(1),
1672                        position_offset: Vec3A::new(0.0, 0.0, 2.0),
1673                        rotation_offset: Quat::IDENTITY,
1674                    }],
1675                    bone_spans: vec![
1676                        crate::MorphOffsetSpan::default(),
1677                        crate::MorphOffsetSpan { start: 0, count: 1 },
1678                    ],
1679                    group_offsets: vec![crate::GroupMorphOffset {
1680                        child_morph: crate::MorphIndex(1),
1681                        ratio: 0.5,
1682                    }],
1683                    group_spans: vec![
1684                        crate::MorphOffsetSpan { start: 0, count: 1 },
1685                        crate::MorphOffsetSpan::default(),
1686                    ],
1687                    ..crate::MorphInit::default()
1688                },
1689            )
1690            .unwrap(),
1691        );
1692        let clip = AnimationClip::new_with_morphs(
1693            Vec::new(),
1694            vec![crate::MorphAnimationBinding {
1695                morph: crate::MorphIndex(0),
1696                track: crate::MorphTrack::from_keyframes(vec![crate::MorphKeyframe::new(0, 1.0)]),
1697            }],
1698        );
1699        let mut runtime = RuntimeInstance::new(model);
1700
1701        runtime.evaluate_clip_frame(&clip, 0.0);
1702
1703        assert_near(runtime.morph_weights()[0], 1.0);
1704        assert_near(runtime.morph_weights()[1], 0.5);
1705        assert_vec3a_near(
1706            translation(runtime.world_matrices()[1]),
1707            Vec3A::new(0.0, 1.0, 1.0),
1708        );
1709    }
1710
1711    #[test]
1712    fn chained_group_morphs_descend_to_bone_morph_weight() {
1713        let model = Arc::new(
1714            ModelArena::new_with_morphs(
1715                vec![
1716                    BoneInit::new(None, Vec3A::ZERO),
1717                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(0.0, 1.0, 0.0)),
1718                ],
1719                Vec::new(),
1720                Vec::new(),
1721                crate::MorphInit {
1722                    morph_count: 3,
1723                    bone_offsets: vec![crate::BoneMorphOffset {
1724                        target_bone: BoneIndex(1),
1725                        position_offset: Vec3A::new(0.0, 0.0, 2.0),
1726                        rotation_offset: Quat::IDENTITY,
1727                    }],
1728                    bone_spans: vec![
1729                        crate::MorphOffsetSpan { start: 0, count: 1 },
1730                        crate::MorphOffsetSpan::default(),
1731                        crate::MorphOffsetSpan::default(),
1732                    ],
1733                    group_offsets: vec![
1734                        crate::GroupMorphOffset {
1735                            child_morph: crate::MorphIndex(0),
1736                            ratio: 0.25,
1737                        },
1738                        crate::GroupMorphOffset {
1739                            child_morph: crate::MorphIndex(1),
1740                            ratio: 0.5,
1741                        },
1742                    ],
1743                    group_spans: vec![
1744                        crate::MorphOffsetSpan::default(),
1745                        crate::MorphOffsetSpan { start: 0, count: 1 },
1746                        crate::MorphOffsetSpan { start: 1, count: 1 },
1747                    ],
1748                    ..crate::MorphInit::default()
1749                },
1750            )
1751            .unwrap(),
1752        );
1753        let clip = AnimationClip::new_with_morphs(
1754            Vec::new(),
1755            vec![crate::MorphAnimationBinding {
1756                morph: crate::MorphIndex(2),
1757                track: crate::MorphTrack::from_keyframes(vec![crate::MorphKeyframe::new(0, 1.0)]),
1758            }],
1759        );
1760        let mut runtime = RuntimeInstance::new(model);
1761
1762        runtime.evaluate_clip_frame(&clip, 0.0);
1763
1764        assert_near(runtime.morph_weights()[2], 1.0);
1765        assert_near(runtime.morph_weights()[1], 0.5);
1766        assert_near(runtime.morph_weights()[0], 0.125);
1767        assert_vec3a_near(
1768            translation(runtime.world_matrices()[1]),
1769            Vec3A::new(0.0, 1.0, 0.25),
1770        );
1771    }
1772
1773    #[test]
1774    fn expand_morphs_noop_when_no_morph_defs() {
1775        let model = Arc::new(ModelArena::new(vec![BoneInit::new(None, Vec3A::ZERO)]).unwrap());
1776        let mut runtime = RuntimeInstance::new_with_morph_count(model, 1);
1777        runtime
1778            .pose_mut()
1779            .set_morph_weight(crate::MorphIndex(0), 1.0);
1780        runtime.expand_morphs();
1781        // No crash = pass
1782        assert_near(runtime.morph_weights()[0], 1.0);
1783    }
1784
1785    #[test]
1786    fn clamps_link_local_rotation_to_angle_limit() {
1787        let model = Arc::new(
1788            ModelArena::new_with_ik(
1789                vec![
1790                    BoneInit::new(None, Vec3A::ZERO),
1791                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(1.0, 0.0, 0.0)),
1792                    BoneInit::new(None, Vec3A::new(0.0, 1.0, 0.0)),
1793                ],
1794                vec![IkSolverInit {
1795                    ik_bone: BoneIndex(2),
1796                    target_bone: BoneIndex(1),
1797                    links: vec![
1798                        IkLinkInit::new(BoneIndex(0)).with_angle_limit(IkAngleLimit::new(
1799                            Vec3A::new(0.0, 0.0, 0.0),
1800                            Vec3A::new(0.0, 0.0, std::f32::consts::FRAC_PI_4),
1801                        )),
1802                    ],
1803                    iteration_count: 1,
1804                    limit_angle: 0.0,
1805                }],
1806            )
1807            .unwrap(),
1808        );
1809        let mut runtime = RuntimeInstance::new(model);
1810
1811        runtime.evaluate_current_pose();
1812
1813        let expected = Vec3A::new(
1814            std::f32::consts::FRAC_1_SQRT_2,
1815            std::f32::consts::FRAC_1_SQRT_2,
1816            0.0,
1817        );
1818        assert_vec3a_near(translation(runtime.world_matrices()[1]), expected);
1819    }
1820
1821    #[test]
1822    fn multi_axis_limited_link_solves_before_clamping() {
1823        let local_effector = Vec3A::X;
1824        let local_target = Vec3A::new(0.25, 0.55, 0.80).normalize();
1825        let limits = IkAngleLimit::new(Vec3A::new(0.0, -1.0, -1.0), Vec3A::new(0.0, 1.0, 1.0));
1826        let base_rotations = vec![Quat::IDENTITY];
1827        let mut ik_rotations = vec![Quat::IDENTITY];
1828        let mut chain_states = vec![super::ChainLinkState {
1829            previous_euler: [0.0; 3],
1830            plane_mode_angle: 0.0,
1831        }];
1832
1833        super::solve_limited_axes_link_step(super::LimitedAxesLinkStepInput {
1834            local_effector: &local_effector,
1835            local_target: &local_target,
1836            link_index: 0,
1837            base_rotations: &base_rotations,
1838            ik_rotations: &mut ik_rotations,
1839            chain_states: &mut chain_states,
1840            limits,
1841            limit_angle: 0.0,
1842        });
1843
1844        let current_direction = ik_rotations[0].mul_vec3a(local_effector).normalize();
1845        let legacy_direction =
1846            legacy_clamp_only_limited_direction(local_effector, local_target, limits);
1847        let current_error = (current_direction - local_target).length();
1848        let legacy_error = (legacy_direction - local_target).length();
1849
1850        assert!(
1851            current_error < legacy_error - 0.015,
1852            "current_error={current_error:.6} legacy_error={legacy_error:.6} current={current_direction:?} legacy={legacy_direction:?} target={local_target:?}"
1853        );
1854        assert!(
1855            chain_states[0].previous_euler[1].abs() > 0.1
1856                && chain_states[0].previous_euler[2].abs() > 0.1,
1857            "multi-axis limited IK should use both Y and Z axes; euler={:?}",
1858            chain_states[0].previous_euler
1859        );
1860    }
1861
1862    #[test]
1863    fn multi_axis_limited_link_applies_limits_to_total_rotation() {
1864        let local_effector = Vec3A::new(0.25, 0.45, 0.85).normalize();
1865        let local_target = Vec3A::new(0.55, 0.15, 0.80).normalize();
1866        let limits = IkAngleLimit::new(Vec3A::new(-1.0, -1.0, 0.0), Vec3A::new(1.0, 1.0, 0.0));
1867        let base_rotations = vec![Quat::from_rotation_z(0.45)];
1868        let mut ik_rotations = vec![Quat::IDENTITY];
1869        let mut chain_states = vec![super::ChainLinkState {
1870            previous_euler: [0.0; 3],
1871            plane_mode_angle: 0.0,
1872        }];
1873
1874        super::solve_limited_axes_link_step(super::LimitedAxesLinkStepInput {
1875            local_effector: &local_effector,
1876            local_target: &local_target,
1877            link_index: 0,
1878            base_rotations: &base_rotations,
1879            ik_rotations: &mut ik_rotations,
1880            chain_states: &mut chain_states,
1881            limits,
1882            limit_angle: 0.0,
1883        });
1884
1885        let base_direction = base_rotations[0].mul_vec3a(local_effector).normalize();
1886        let effective = (ik_rotations[0] * base_rotations[0]).normalize();
1887        let stale_direction = limited_direction_without_fixed_axis_working_update(
1888            local_effector,
1889            local_target,
1890            base_rotations[0],
1891            limits,
1892        );
1893        let solved_direction = effective.mul_vec3a(local_effector).normalize();
1894        assert_near(chain_states[0].previous_euler[2], 0.0);
1895        assert!(
1896            (solved_direction - stale_direction).length() > 0.05,
1897            "fixed axis clamp should affect later axis solve; solved={solved_direction:?} stale={stale_direction:?}"
1898        );
1899        assert!(
1900            (solved_direction - local_target).length() < (base_direction - local_target).length(),
1901            "non-identity base should still solve toward target; base={base_direction:?} solved={solved_direction:?} target={local_target:?}"
1902        );
1903    }
1904
1905    fn limited_direction_without_fixed_axis_working_update(
1906        local_effector: Vec3A,
1907        local_target: Vec3A,
1908        base: Quat,
1909        limits: IkAngleLimit,
1910    ) -> Vec3A {
1911        let mut total_euler =
1912            super::decompose_euler_xyz(&super::quat_to_rotation_mat3(base), &[0.0; 3]);
1913        let mut working_effector = local_effector;
1914        let target = local_target.normalize();
1915
1916        for axis_index in [2usize, 1, 0] {
1917            let (lower, upper) = super::limit_axis_bounds(limits, axis_index);
1918            if lower == 0.0 && upper == 0.0 {
1919                total_euler[axis_index] = total_euler[axis_index].clamp(lower, upper);
1920                continue;
1921            }
1922
1923            let axis = super::axis_vec(axis_index);
1924            let signed_angle = super::signed_projected_angle(working_effector, target, axis);
1925            if signed_angle.abs() <= 1.0e-6 {
1926                continue;
1927            }
1928            let next = (total_euler[axis_index] + signed_angle).clamp(lower, upper);
1929            let applied = next - total_euler[axis_index];
1930            total_euler[axis_index] = next;
1931            if applied.abs() > 0.0 {
1932                working_effector =
1933                    Quat::from_axis_angle(axis.into(), applied).mul_vec3a(working_effector);
1934            }
1935        }
1936
1937        super::euler_xyz_to_quat(&total_euler)
1938            .normalize()
1939            .mul_vec3a(local_effector)
1940            .normalize()
1941    }
1942
1943    fn legacy_clamp_only_limited_direction(
1944        local_effector: Vec3A,
1945        local_target: Vec3A,
1946        limits: IkAngleLimit,
1947    ) -> Vec3A {
1948        let local_eff_n = local_effector.normalize();
1949        let local_tgt_n = local_target.normalize();
1950        let dot = local_eff_n.dot(local_tgt_n).clamp(-1.0, 1.0);
1951        let angle = dot.acos();
1952        let axis = local_eff_n.cross(local_tgt_n);
1953        let axis_vec = if axis.length() < 1e-5 {
1954            if dot > -1.0 + 1e-5 {
1955                return local_eff_n;
1956            }
1957            let basis = if local_eff_n.x.abs() < 0.9 {
1958                Vec3A::new(1.0, 0.0, 0.0)
1959            } else {
1960                Vec3A::new(0.0, 1.0, 0.0)
1961            };
1962            local_eff_n.cross(basis).normalize()
1963        } else {
1964            axis.normalize()
1965        };
1966        let rotation = Quat::from_axis_angle(axis_vec.into(), angle).normalize();
1967        let euler = super::decompose_euler_xyz(&super::quat_to_rotation_mat3(rotation), &[0.0; 3]);
1968        let clamped = [
1969            euler[0].clamp(limits.min.x, limits.max.x),
1970            euler[1].clamp(limits.min.y, limits.max.y),
1971            euler[2].clamp(limits.min.z, limits.max.z),
1972        ];
1973        super::euler_xyz_to_quat(&clamped)
1974            .normalize()
1975            .mul_vec3a(local_effector)
1976            .normalize()
1977    }
1978
1979    #[test]
1980    fn plane_link_step_matches_saba_total_axis_rotation() {
1981        let base = Quat::from_rotation_x(0.3);
1982        let base_rotations = vec![base];
1983        let mut ik_rotations = vec![Quat::IDENTITY];
1984        let mut chain_states = vec![super::ChainLinkState {
1985            previous_euler: [0.0; 3],
1986            plane_mode_angle: 0.0,
1987        }];
1988        let local_effector = Vec3A::X;
1989        let local_target = Vec3A::Y;
1990
1991        super::solve_plane_link_step(super::PlaneLinkStepInput {
1992            local_effector: &local_effector,
1993            local_target: &local_target,
1994            link_index: 0,
1995            base_rotations: &base_rotations,
1996            ik_rotations: &mut ik_rotations,
1997            chain_states: &mut chain_states,
1998            axis_index: 2,
1999            limits: IkAngleLimit::new(
2000                Vec3A::new(-std::f32::consts::PI, 0.0, -std::f32::consts::PI),
2001                Vec3A::new(std::f32::consts::PI, 0.0, std::f32::consts::PI),
2002            ),
2003            iteration: 0,
2004            limit_angle: 0.0,
2005        });
2006
2007        let effective = (ik_rotations[0] * base_rotations[0]).normalize();
2008        assert_near(
2009            chain_states[0].plane_mode_angle,
2010            std::f32::consts::FRAC_PI_2,
2011        );
2012        assert_vec3a_near(
2013            effective.mul_vec3a(Vec3A::X),
2014            Quat::from_rotation_z(std::f32::consts::FRAC_PI_2).mul_vec3a(Vec3A::X),
2015        );
2016        assert_vec3a_near(effective.mul_vec3a(Vec3A::Z), Vec3A::Z);
2017    }
2018
2019    #[test]
2020    fn append_rotation_propagates_post_ik_link_rotation() {
2021        let model = Arc::new(
2022            ModelArena::new_full(
2023                vec![
2024                    BoneInit::new(None, Vec3A::ZERO),
2025                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(1.0, 0.0, 0.0)),
2026                    BoneInit::new(None, Vec3A::new(0.0, 1.0, 0.0)),
2027                    BoneInit::new(None, Vec3A::ZERO),
2028                    BoneInit::new(Some(BoneIndex(3)), Vec3A::new(1.0, 0.0, 0.0)),
2029                ],
2030                vec![IkSolverInit {
2031                    ik_bone: BoneIndex(2),
2032                    target_bone: BoneIndex(1),
2033                    links: vec![IkLinkInit::new(BoneIndex(0))],
2034                    iteration_count: 1,
2035                    limit_angle: 0.0,
2036                }],
2037                vec![AppendTransformInit::new(BoneIndex(3), BoneIndex(0), 1.0).with_rotation()],
2038            )
2039            .unwrap(),
2040        );
2041        let mut runtime = RuntimeInstance::new(model);
2042
2043        runtime.evaluate_current_pose();
2044
2045        assert_vec3a_near(
2046            translation(runtime.world_matrices()[4]),
2047            Vec3A::new(0.0, 1.0, 0.0),
2048        );
2049    }
2050
2051    #[test]
2052    fn scratch_ik_capacities_stable_after_repeated_evaluate() {
2053        let model = Arc::new(
2054            ModelArena::new_with_ik(
2055                vec![
2056                    BoneInit::new(None, Vec3A::ZERO),
2057                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(1.0, 0.0, 0.0)),
2058                    BoneInit::new(None, Vec3A::new(0.0, 1.0, 0.0)),
2059                    BoneInit::new(None, Vec3A::ZERO),
2060                    BoneInit::new(Some(BoneIndex(3)), Vec3A::new(1.0, 0.0, 0.0)),
2061                    BoneInit::new(Some(BoneIndex(4)), Vec3A::new(1.0, 0.0, 0.0)),
2062                    BoneInit::new(None, Vec3A::new(0.0, 1.0, 0.0)),
2063                ],
2064                vec![
2065                    IkSolverInit {
2066                        ik_bone: BoneIndex(2),
2067                        target_bone: BoneIndex(1),
2068                        links: vec![IkLinkInit::new(BoneIndex(0))],
2069                        iteration_count: 1,
2070                        limit_angle: 0.0,
2071                    },
2072                    IkSolverInit {
2073                        ik_bone: BoneIndex(6),
2074                        target_bone: BoneIndex(5),
2075                        links: vec![IkLinkInit::new(BoneIndex(3)), IkLinkInit::new(BoneIndex(4))],
2076                        iteration_count: 1,
2077                        limit_angle: 0.0,
2078                    },
2079                ],
2080            )
2081            .unwrap(),
2082        );
2083        let mut runtime = RuntimeInstance::new(model);
2084
2085        runtime.evaluate_current_pose();
2086
2087        let cap_links = runtime.ik_scratch.links.capacity();
2088        let cap_base = runtime.ik_scratch.base_rotations.capacity();
2089        let cap_ik = runtime.ik_scratch.ik_rotations.capacity();
2090        let cap_best = runtime.ik_scratch.best_ik_rotations.capacity();
2091        let cap_chain = runtime.ik_scratch.chain_states.capacity();
2092
2093        for _ in 0..10 {
2094            runtime.evaluate_current_pose();
2095        }
2096
2097        assert_eq!(runtime.ik_scratch.links.capacity(), cap_links);
2098        assert_eq!(runtime.ik_scratch.base_rotations.capacity(), cap_base);
2099        assert_eq!(runtime.ik_scratch.ik_rotations.capacity(), cap_ik);
2100        assert_eq!(runtime.ik_scratch.best_ik_rotations.capacity(), cap_best);
2101        assert_eq!(runtime.ik_scratch.chain_states.capacity(), cap_chain);
2102    }
2103
2104    #[test]
2105    fn scratch_morph_capacity_stable_after_repeated_clip_frame() {
2106        let model = Arc::new(
2107            ModelArena::new_with_morphs(
2108                vec![
2109                    BoneInit::new(None, Vec3A::ZERO),
2110                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(0.0, 1.0, 0.0)),
2111                ],
2112                Vec::new(),
2113                Vec::new(),
2114                crate::MorphInit {
2115                    morph_count: 2,
2116                    bone_offsets: vec![crate::BoneMorphOffset {
2117                        target_bone: BoneIndex(1),
2118                        position_offset: Vec3A::new(0.0, 0.0, 2.0),
2119                        rotation_offset: Quat::IDENTITY,
2120                    }],
2121                    bone_spans: vec![
2122                        crate::MorphOffsetSpan { start: 0, count: 1 },
2123                        crate::MorphOffsetSpan::default(),
2124                    ],
2125                    group_offsets: vec![crate::GroupMorphOffset {
2126                        child_morph: crate::MorphIndex(0),
2127                        ratio: 0.5,
2128                    }],
2129                    group_spans: vec![
2130                        crate::MorphOffsetSpan::default(),
2131                        crate::MorphOffsetSpan { start: 0, count: 1 },
2132                    ],
2133                    ..crate::MorphInit::default()
2134                },
2135            )
2136            .unwrap(),
2137        );
2138        let clip = AnimationClip::new_with_morphs(
2139            Vec::new(),
2140            vec![crate::MorphAnimationBinding {
2141                morph: crate::MorphIndex(1),
2142                track: crate::MorphTrack::from_keyframes(vec![
2143                    crate::MorphKeyframe::new(0, 0.0),
2144                    crate::MorphKeyframe::new(10, 1.0),
2145                ]),
2146            }],
2147        );
2148        let mut runtime = RuntimeInstance::new_with_morph_count(model, 2);
2149
2150        runtime.evaluate_clip_frame(&clip, 5.0);
2151
2152        let cap_expanded = runtime.morph_scratch.expanded_weights.capacity();
2153
2154        for _ in 0..10 {
2155            runtime.evaluate_clip_frame(&clip, 5.0);
2156        }
2157
2158        assert_eq!(
2159            runtime.morph_scratch.expanded_weights.capacity(),
2160            cap_expanded
2161        );
2162    }
2163}