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            let mut best_distance = f32::MAX;
305
306            self.apply_ik_link_rotations(&links, &base_rotations, &ik_rotations);
307            self.update_world_matrices();
308
309            let mut broke_early = false;
310            let mut final_distance = f32::MAX;
311            for _iteration in 0..iteration_count {
312                // Tolerance early exit
313                let eff_pos = translation(self.pose.world_matrices()[target_bone.as_usize()]);
314                let ik_pos = translation(self.pose.world_matrices()[ik_bone.as_usize()]);
315                final_distance = (eff_pos - ik_pos).length();
316                if final_distance <= tolerance {
317                    self.ik_stats[ik_index].tolerance_precheck_breaks += 1;
318                    broke_early = true;
319                    break;
320                }
321                self.ik_stats[ik_index].executed_iterations += 1;
322
323                for link_index in 0..link_count {
324                    let link = &links[link_index];
325                    let link_bone = link.bone;
326                    self.ik_stats[ik_index].link_visits += 1;
327
328                    if link_bone == target_bone {
329                        continue;
330                    }
331
332                    let link_world = self.pose.world_matrices()[link_bone.as_usize()];
333                    let link_pos = translation(link_world);
334                    let eff_pos = translation(self.pose.world_matrices()[target_bone.as_usize()]);
335                    let ik_pos = translation(self.pose.world_matrices()[ik_bone.as_usize()]);
336
337                    // Transform direction vectors to link-local space
338                    let link_world_rot = rotation(link_world);
339                    let local_effector = link_world_rot.inverse().mul_vec3a(eff_pos - link_pos);
340                    let local_target = link_world_rot.inverse().mul_vec3a(ik_pos - link_pos);
341
342                    if local_effector.length_squared() <= f32::EPSILON
343                        || local_target.length_squared() <= f32::EPSILON
344                    {
345                        continue;
346                    }
347
348                    let single_axis = get_single_axis_limit(link.angle_limit);
349
350                    if let (Some(angle_limit), Some(axis_index)) = (link.angle_limit, single_axis) {
351                        solve_plane_link_step(PlaneLinkStepInput {
352                            local_effector: &local_effector,
353                            local_target: &local_target,
354                            link_index,
355                            base_rotations: &base_rotations,
356                            ik_rotations: &mut ik_rotations,
357                            chain_states: &mut chain_states,
358                            axis_index,
359                            limits: angle_limit,
360                            iteration: _iteration,
361                            limit_angle,
362                        });
363                    } else {
364                        let local_eff_n = local_effector.normalize();
365                        let local_tgt_n = local_target.normalize();
366                        let dot = local_eff_n.dot(local_tgt_n).clamp(-1.0, 1.0);
367                        let mut angle = dot.acos();
368
369                        let tiny_angle = 1e-3 * std::f32::consts::PI / 180.0;
370                        if angle < tiny_angle {
371                            continue;
372                        }
373
374                        if limit_angle > 0.0 {
375                            angle = angle.min(limit_angle);
376                        }
377
378                        let axis = local_eff_n.cross(local_tgt_n);
379                        let axis_vec = if axis.length() < 1e-5 {
380                            if dot > -1.0 + 1e-5 {
381                                continue;
382                            }
383                            let basis = if local_eff_n.x.abs() < 0.9 {
384                                Vec3A::new(1.0, 0.0, 0.0)
385                            } else {
386                                Vec3A::new(0.0, 1.0, 0.0)
387                            };
388                            local_eff_n.cross(basis).normalize()
389                        } else {
390                            axis.normalize()
391                        };
392
393                        let delta = Quat::from_axis_angle(axis_vec.into(), angle);
394                        let base = base_rotations[link_index];
395                        let ik = ik_rotations[link_index];
396                        let mut chain_rotation = (ik * base * delta).normalize();
397
398                        if let Some(angle_limit) = link.angle_limit {
399                            chain_rotation = clamp_limited_rotation(
400                                chain_rotation,
401                                angle_limit,
402                                &mut chain_states[link_index],
403                                limit_angle,
404                            );
405                        }
406
407                        ik_rotations[link_index] = (chain_rotation * base.inverse()).normalize();
408                    }
409
410                    self.apply_ik_link_rotations(&links, &base_rotations, &ik_rotations);
411                    self.update_world_matrices_from_bone(link_bone);
412                    self.ik_stats[ik_index].link_steps += 1;
413                }
414
415                // Best rotations tracking
416                let current_distance = {
417                    let eff = translation(self.pose.world_matrices()[target_bone.as_usize()]);
418                    let ik = translation(self.pose.world_matrices()[ik_bone.as_usize()]);
419                    (eff - ik).length()
420                };
421                final_distance = current_distance;
422
423                if current_distance < best_distance {
424                    best_distance = current_distance;
425                    best_ik_rotations.copy_from_slice(&ik_rotations);
426                    if current_distance <= tolerance {
427                        self.ik_stats[ik_index].tolerance_post_iteration_breaks += 1;
428                        broke_early = true;
429                        break;
430                    }
431                } else {
432                    self.ik_stats[ik_index].rollback_breaks += 1;
433                    ik_rotations.copy_from_slice(&best_ik_rotations);
434                    self.apply_ik_link_rotations(&links, &base_rotations, &ik_rotations);
435                    if let Some(start_position) = self.min_link_eval_order_position(&links) {
436                        self.update_world_matrices_from_eval_order_position(start_position);
437                    }
438                    broke_early = true;
439                    break;
440                }
441            }
442            self.ik_stats[ik_index].final_distance_sum += f64::from(final_distance);
443            self.ik_stats[ik_index].final_distance_max = self.ik_stats[ik_index]
444                .final_distance_max
445                .max(final_distance);
446            if !broke_early {
447                self.ik_stats[ik_index].max_iteration_exhaustions += 1;
448                self.ik_stats[ik_index].exhausted_final_distance_sum += f64::from(final_distance);
449                self.ik_stats[ik_index].exhausted_final_distance_max = self.ik_stats[ik_index]
450                    .exhausted_final_distance_max
451                    .max(final_distance);
452            }
453
454            // Apply final best effective rotations
455            self.apply_ik_link_rotations(&links, &base_rotations, &best_ik_rotations);
456            if let Some(start_position) = self.min_link_eval_order_position(&links) {
457                self.update_world_matrices_from_eval_order_position(start_position);
458            }
459        }
460
461        self.ik_scratch.links = links;
462        self.ik_scratch.base_rotations = base_rotations;
463        self.ik_scratch.ik_rotations = ik_rotations;
464        self.ik_scratch.best_ik_rotations = best_ik_rotations;
465        self.ik_scratch.chain_states = chain_states;
466    }
467
468    fn apply_ik_link_rotations(
469        &mut self,
470        links: &[crate::IkLink],
471        base_rotations: &[Quat],
472        ik_rotations: &[Quat],
473    ) {
474        for (i, link) in links.iter().enumerate() {
475            let effective = (ik_rotations[i] * base_rotations[i]).normalize();
476            self.pose.set_local_rotation(link.bone, effective);
477        }
478    }
479
480    pub fn evaluate_rest_pose(&mut self) {
481        self.pose.reset_local_pose();
482        self.evaluate_current_pose();
483    }
484
485    pub fn evaluate_clip_frame(&mut self, clip: &AnimationClip, frame: f32) {
486        clip.apply_to_pose(frame, &mut self.pose);
487        self.expand_morphs();
488        self.evaluate_current_pose();
489    }
490
491    pub fn evaluate_clip_frame_with_ik_options(
492        &mut self,
493        clip: &AnimationClip,
494        frame: f32,
495        options: IkSolveOptions,
496    ) {
497        clip.apply_to_pose(frame, &mut self.pose);
498        self.expand_morphs();
499        self.evaluate_current_pose_with_ik_options(options);
500    }
501
502    /// Evaluate a clip frame but stop before solving IK. Applies the clip to
503    /// the pose, expands morphs, and updates world matrices - the same setup
504    /// as [`Self::evaluate_clip_frame`] but without calling `solve_enabled_ik`.
505    /// Useful for diagnostics that need to inspect pre-IK runtime state.
506    pub fn evaluate_clip_frame_without_ik(&mut self, clip: &AnimationClip, frame: f32) {
507        clip.apply_to_pose(frame, &mut self.pose);
508        self.expand_morphs();
509        self.update_world_matrices();
510    }
511
512    /// Expand group morphs and apply bone morph offsets.
513    ///
514    /// Called automatically from [`Self::evaluate_clip_frame`]. Exposed publicly so
515    /// that hosts manually driving [`PoseArena`] can trigger morph expansion
516    /// before calling [`Self::evaluate_current_pose`].
517    pub fn expand_morphs(&mut self) {
518        self.expand_group_morphs();
519        self.apply_bone_morphs();
520    }
521
522    /// Pass 1: expand all group morph weights (updates morph_weights in-place).
523    /// Group morph children may appear before or after their parents in PMX, so
524    /// expansion follows the graph recursively using the model-validated
525    /// cycle-free group morph spans.
526    fn expand_group_morphs(&mut self) {
527        let spans = self.model.group_morph_spans();
528        let offsets = self.model.group_morph_offsets();
529        if spans.is_empty() || offsets.is_empty() {
530            return;
531        }
532        let mc = self.model.morph_count() as usize;
533        self.morph_scratch.expanded_weights.clear();
534        self.morph_scratch
535            .expanded_weights
536            .extend_from_slice(&self.pose.morph_weights()[..mc]);
537
538        for (morph_idx, &w) in self.pose.morph_weights()[..mc].iter().enumerate() {
539            if w == 0.0 {
540                continue;
541            }
542            expand_group_morph_weight(
543                morph_idx,
544                w,
545                spans,
546                offsets,
547                &mut self.morph_scratch.expanded_weights,
548            );
549        }
550        for (i, &w) in self.morph_scratch.expanded_weights.iter().enumerate() {
551            self.pose.set_morph_weight(MorphIndex(i as u32), w);
552        }
553    }
554
555    /// Pass 2: apply bone morph offsets using the final (expanded) morph
556    /// weights.
557    fn apply_bone_morphs(&mut self) {
558        let spans = self.model.bone_morph_spans();
559        let offsets = self.model.bone_morph_offsets();
560        if spans.is_empty() || offsets.is_empty() {
561            return;
562        }
563        for (morph_idx, span) in spans.iter().enumerate() {
564            let weight = self.pose.morph_weight(MorphIndex(morph_idx as u32));
565            if weight == 0.0 {
566                continue;
567            }
568            for i in span.start..span.start + span.count {
569                let off = &offsets[i as usize];
570                let pos = self.pose.local_position_offset(off.target_bone);
571                self.pose
572                    .set_local_position_offset(off.target_bone, pos + off.position_offset * weight);
573                let rot = self.pose.local_rotation(off.target_bone);
574                let scaled = Quat::IDENTITY.slerp(off.rotation_offset, weight);
575                self.pose
576                    .set_local_rotation(off.target_bone, (rot * scaled).normalize());
577            }
578        }
579    }
580
581    #[inline]
582    pub fn world_matrices(&self) -> &[Mat4] {
583        self.pose.world_matrices()
584    }
585
586    #[cfg(test)]
587    fn reset_world_matrix_bone_update_count(&mut self) {
588        self.world_matrix_bone_update_count = 0;
589    }
590
591    #[cfg(test)]
592    fn world_matrix_bone_update_count(&self) -> usize {
593        self.world_matrix_bone_update_count
594    }
595
596    #[inline]
597    pub fn skinning_matrices(&self) -> &[Mat4] {
598        self.pose.skinning_matrices()
599    }
600
601    #[inline]
602    pub fn morph_weights(&self) -> &[f32] {
603        self.pose.morph_weights()
604    }
605
606    pub fn reset_ik_runtime_stats(&mut self) {
607        for stats in &mut self.ik_stats {
608            stats.reset();
609        }
610    }
611
612    pub fn ik_runtime_stats(&self) -> &[IkSolverRuntimeStats] {
613        &self.ik_stats
614    }
615
616    #[inline]
617    pub fn ik_enabled(&self) -> &[u8] {
618        self.pose.ik_enabled()
619    }
620}
621
622fn expand_group_morph_weight(
623    morph_idx: usize,
624    weight: f32,
625    spans: &[crate::MorphOffsetSpan],
626    offsets: &[crate::GroupMorphOffset],
627    expanded_weights: &mut [f32],
628) {
629    let span = spans[morph_idx];
630    for i in span.start..span.start + span.count {
631        let off = &offsets[i as usize];
632        let child = off.child_morph.as_usize();
633        let contribution = weight * off.ratio;
634        expanded_weights[child] += contribution;
635        if spans[child].count > 0 {
636            expand_group_morph_weight(child, contribution, spans, offsets, expanded_weights);
637        }
638    }
639}
640
641fn translation(matrix: Mat4) -> Vec3A {
642    Vec3A::from_vec4(matrix.w_axis)
643}
644
645fn rotation(matrix: Mat4) -> Quat {
646    matrix.to_scale_rotation_translation().1
647}
648
649fn constrain_rotation_to_axis(rotation: Quat, axis: Vec3A) -> Quat {
650    let axis = axis.normalize();
651    let vector = Vec3A::new(rotation.x, rotation.y, rotation.z);
652    let projected = axis * vector.dot(axis);
653    let twist = Quat::from_xyzw(projected.x, projected.y, projected.z, rotation.w);
654    if twist.length_squared() <= f32::EPSILON {
655        Quat::IDENTITY
656    } else {
657        twist.normalize()
658    }
659}
660
661#[derive(Debug)]
662struct ChainLinkState {
663    previous_euler: [f32; 3],
664    plane_mode_angle: f32,
665}
666
667fn get_single_axis_limit(limit: Option<crate::IkAngleLimit>) -> Option<usize> {
668    let limit = limit?;
669    let has = [
670        limit.min.x != 0.0 || limit.max.x != 0.0,
671        limit.min.y != 0.0 || limit.max.y != 0.0,
672        limit.min.z != 0.0 || limit.max.z != 0.0,
673    ];
674    if has[0]
675        && limit.min.y == 0.0
676        && limit.max.y == 0.0
677        && limit.min.z == 0.0
678        && limit.max.z == 0.0
679    {
680        return Some(0);
681    }
682    if has[1]
683        && limit.min.x == 0.0
684        && limit.max.x == 0.0
685        && limit.min.z == 0.0
686        && limit.max.z == 0.0
687    {
688        return Some(1);
689    }
690    if has[2]
691        && limit.min.x == 0.0
692        && limit.max.x == 0.0
693        && limit.min.y == 0.0
694        && limit.max.y == 0.0
695    {
696        return Some(2);
697    }
698    None
699}
700
701fn clamp_limited_rotation(
702    rotation: Quat,
703    limits: crate::IkAngleLimit,
704    state: &mut ChainLinkState,
705    limit_angle: f32,
706) -> Quat {
707    let mat = quat_to_rotation_mat3(rotation);
708    let euler = decompose_euler_xyz(&mat, &state.previous_euler);
709    let clamped: [f32; 3] = [
710        euler[0].clamp(limits.min.x, limits.max.x),
711        euler[1].clamp(limits.min.y, limits.max.y),
712        euler[2].clamp(limits.min.z, limits.max.z),
713    ];
714    let mut limited_step: [f32; 3] = [0.0; 3];
715    for i in 0..3 {
716        let delta = clamped[i] - state.previous_euler[i];
717        limited_step[i] = if limit_angle > 0.0 {
718            delta.clamp(-limit_angle, limit_angle) + state.previous_euler[i]
719        } else {
720            clamped[i]
721        };
722    }
723    state.previous_euler = limited_step;
724    euler_xyz_to_quat(&limited_step)
725}
726
727fn quat_to_rotation_mat3(rotation: Quat) -> [f32; 9] {
728    let [x, y, z, w] = rotation.normalize().to_array();
729    let x2 = x + x;
730    let y2 = y + y;
731    let z2 = z + z;
732    let xx = x * x2;
733    let xy = x * y2;
734    let xz = x * z2;
735    let yy = y * y2;
736    let yz = y * z2;
737    let zz = z * z2;
738    let wx = w * x2;
739    let wy = w * y2;
740    let wz = w * z2;
741    [
742        1.0 - (yy + zz),
743        xy + wz,
744        xz - wy,
745        xy - wz,
746        1.0 - (xx + zz),
747        yz + wx,
748        xz + wy,
749        yz - wx,
750        1.0 - (xx + yy),
751    ]
752}
753
754fn decompose_euler_xyz(mat: &[f32; 9], before: &[f32; 3]) -> [f32; 3] {
755    let sy = -mat[2];
756    let mut result: [f32; 3];
757    if 1.0 - sy.abs() < 1e-6 {
758        let y = sy.asin();
759        let sx = before[0].sin();
760        let sz = before[2].sin();
761        if sx.abs() < sz.abs() {
762            let cx = before[0].cos();
763            result = if cx > 0.0 {
764                [0.0, y, (-mat[3]).asin()]
765            } else {
766                [std::f32::consts::PI, y, mat[3].asin()]
767            };
768        } else {
769            let cz = before[2].cos();
770            result = if cz > 0.0 {
771                [(-mat[7]).asin(), y, 0.0]
772            } else {
773                [mat[7].asin(), y, std::f32::consts::PI]
774            };
775        }
776    } else {
777        result = [mat[5].atan2(mat[8]), (-mat[2]).asin(), mat[1].atan2(mat[0])];
778    }
779
780    let pi = std::f32::consts::PI;
781    let candidates: [[f32; 3]; 8] = [
782        [result[0] + pi, pi - result[1], result[2] + pi],
783        [result[0] + pi, pi - result[1], result[2] - pi],
784        [result[0] + pi, -pi - result[1], result[2] + pi],
785        [result[0] + pi, -pi - result[1], result[2] - pi],
786        [result[0] - pi, pi - result[1], result[2] + pi],
787        [result[0] - pi, pi - result[1], result[2] - pi],
788        [result[0] - pi, -pi - result[1], result[2] + pi],
789        [result[0] - pi, -pi - result[1], result[2] - pi],
790    ];
791    let mut min_error = diff_angle(result[0], before[0]).abs()
792        + diff_angle(result[1], before[1]).abs()
793        + diff_angle(result[2], before[2]).abs();
794    for candidate in &candidates {
795        let error = diff_angle(candidate[0], before[0]).abs()
796            + diff_angle(candidate[1], before[1]).abs()
797            + diff_angle(candidate[2], before[2]).abs();
798        if error < min_error {
799            min_error = error;
800            result = *candidate;
801        }
802    }
803    result
804}
805
806fn diff_angle(a: f32, b: f32) -> f32 {
807    let diff = normalize_angle(a) - normalize_angle(b);
808    if diff > std::f32::consts::PI {
809        diff - std::f32::consts::TAU
810    } else if diff < -std::f32::consts::PI {
811        diff + std::f32::consts::TAU
812    } else {
813        diff
814    }
815}
816
817fn normalize_angle(angle: f32) -> f32 {
818    let mut result = angle;
819    while result >= std::f32::consts::TAU {
820        result -= std::f32::consts::TAU;
821    }
822    while result < 0.0 {
823        result += std::f32::consts::TAU;
824    }
825    result
826}
827
828fn euler_xyz_to_quat(euler: &[f32; 3]) -> Quat {
829    let [x, y, z] = *euler;
830    let c1 = (x / 2.0).cos();
831    let c2 = (y / 2.0).cos();
832    let c3 = (z / 2.0).cos();
833    let s1 = (x / 2.0).sin();
834    let s2 = (y / 2.0).sin();
835    let s3 = (z / 2.0).sin();
836    Quat::from_xyzw(
837        s1 * c2 * c3 + c1 * s2 * s3,
838        c1 * s2 * c3 - s1 * c2 * s3,
839        c1 * c2 * s3 + s1 * s2 * c3,
840        c1 * c2 * c3 - s1 * s2 * s3,
841    )
842}
843
844struct PlaneLinkStepInput<'a> {
845    local_effector: &'a Vec3A,
846    local_target: &'a Vec3A,
847    link_index: usize,
848    base_rotations: &'a [Quat],
849    ik_rotations: &'a mut [Quat],
850    chain_states: &'a mut [ChainLinkState],
851    axis_index: usize,
852    limits: crate::IkAngleLimit,
853    iteration: usize,
854    limit_angle: f32,
855}
856
857fn solve_plane_link_step(input: PlaneLinkStepInput<'_>) {
858    let rotate_axis = match input.axis_index {
859        0 => Vec3A::new(1.0, 0.0, 0.0),
860        1 => Vec3A::new(0.0, 1.0, 0.0),
861        _ => Vec3A::new(0.0, 0.0, 1.0),
862    };
863    let local_eff_n = input.local_effector.normalize();
864    let local_tgt_n = input.local_target.normalize();
865
866    let dot = local_eff_n.dot(local_tgt_n).clamp(-1.0, 1.0);
867    let raw_angle = dot.acos();
868    let capped_angle = if input.limit_angle > 0.0 {
869        raw_angle.min(input.limit_angle)
870    } else {
871        raw_angle
872    };
873
874    let target_vec1 =
875        Quat::from_axis_angle(rotate_axis.into(), capped_angle).mul_vec3a(local_eff_n);
876    let target_vec2 =
877        Quat::from_axis_angle(rotate_axis.into(), -capped_angle).mul_vec3a(local_eff_n);
878    let signed_angle = if target_vec1.dot(local_tgt_n) > target_vec2.dot(local_tgt_n) {
879        capped_angle
880    } else {
881        -capped_angle
882    };
883
884    let state = &mut input.chain_states[input.link_index];
885    let mut next_angle = state.plane_mode_angle + signed_angle;
886    let (lower, upper) = match input.axis_index {
887        0 => (input.limits.min.x, input.limits.max.x),
888        1 => (input.limits.min.y, input.limits.max.y),
889        _ => (input.limits.min.z, input.limits.max.z),
890    };
891    let base = input.base_rotations[input.link_index];
892    // Extract the base rotation angle on the limited axis so we can clamp the
893    // *total* (base + IK) rotation to the prescribed limits rather than
894    // clamping only the IK contribution.
895    let base_mat = quat_to_rotation_mat3(base);
896    let base_euler = decompose_euler_xyz(&base_mat, &[0.0; 3]);
897    let base_axis_angle = base_euler[input.axis_index];
898    let effective_min = lower - base_axis_angle;
899    let effective_max = upper - base_axis_angle;
900
901    if input.iteration == 0 && (next_angle < effective_min || next_angle > effective_max) {
902        if -next_angle > effective_min && -next_angle < effective_max {
903            next_angle = -next_angle;
904        } else {
905            let half = (effective_min + effective_max) * 0.5;
906            if (half - next_angle).abs() > (half + next_angle).abs() {
907                next_angle = -next_angle;
908            }
909        }
910    }
911
912    state.plane_mode_angle = next_angle.clamp(effective_min, effective_max);
913    // Preserve the base (VMD) rotation by combining it with the IK adjustment,
914    // matching the convention used by the general (non-plane) path.
915    let ik_adj = Quat::from_axis_angle(rotate_axis.into(), state.plane_mode_angle);
916    input.ik_rotations[input.link_index] = (base * ik_adj * base.inverse()).normalize();
917}
918
919#[cfg(test)]
920mod tests {
921    use std::sync::Arc;
922
923    use glam::{Quat, Vec3A};
924
925    use crate::{
926        AnimationClip, AppendTransformInit, BoneAnimationBinding, BoneIndex, BoneInit,
927        IkAngleLimit, IkLinkInit, IkSolverInit, ModelArena, MovableBoneKeyframe, MovableBoneTrack,
928        RuntimeInstance,
929    };
930
931    fn translation(matrix: glam::Mat4) -> Vec3A {
932        Vec3A::from_vec4(matrix.w_axis)
933    }
934
935    fn assert_vec3a_near(actual: Vec3A, expected: Vec3A) {
936        let delta = (actual - expected).abs();
937        assert!(
938            delta.x < 1.0e-5 && delta.y < 1.0e-5 && delta.z < 1.0e-5,
939            "actual={actual:?} expected={expected:?} delta={delta:?}"
940        );
941    }
942
943    #[test]
944    fn evaluates_rest_pose_world_matrices() {
945        let model = Arc::new(
946            ModelArena::new(vec![
947                BoneInit::new(None, Vec3A::new(1.0, 0.0, 0.0)),
948                BoneInit::new(Some(BoneIndex(0)), Vec3A::new(0.0, 2.0, 0.0)),
949            ])
950            .unwrap(),
951        );
952        let mut runtime = RuntimeInstance::new(model);
953
954        runtime.evaluate_rest_pose();
955
956        assert_vec3a_near(
957            translation(runtime.world_matrices()[0]),
958            Vec3A::new(1.0, 0.0, 0.0),
959        );
960        assert_vec3a_near(
961            translation(runtime.world_matrices()[1]),
962            Vec3A::new(1.0, 2.0, 0.0),
963        );
964    }
965
966    #[test]
967    fn evaluates_current_pose_with_parent_rotation() {
968        let model = Arc::new(
969            ModelArena::new(vec![
970                BoneInit::new(None, Vec3A::new(1.0, 0.0, 0.0)),
971                BoneInit::new(Some(BoneIndex(0)), Vec3A::new(0.0, 2.0, 0.0)),
972            ])
973            .unwrap(),
974        );
975        let mut runtime = RuntimeInstance::new(model);
976
977        runtime.pose_mut().set_local_rotation(
978            BoneIndex(0),
979            Quat::from_rotation_z(std::f32::consts::FRAC_PI_2),
980        );
981        runtime.evaluate_current_pose();
982
983        assert_vec3a_near(
984            translation(runtime.world_matrices()[1]),
985            Vec3A::new(-1.0, 0.0, 0.0),
986        );
987    }
988
989    #[test]
990    fn fixed_axis_bone_rotation_keeps_only_axis_twist() {
991        let model = Arc::new(
992            ModelArena::new(vec![
993                BoneInit::new(None, Vec3A::ZERO).with_fixed_axis(Vec3A::Y),
994                BoneInit::new(Some(BoneIndex(0)), Vec3A::X),
995            ])
996            .unwrap(),
997        );
998        let mut runtime = RuntimeInstance::new(model);
999
1000        runtime.pose_mut().set_local_rotation(
1001            BoneIndex(0),
1002            (Quat::from_rotation_y(std::f32::consts::FRAC_PI_2)
1003                * Quat::from_rotation_x(std::f32::consts::FRAC_PI_2))
1004            .normalize(),
1005        );
1006        runtime.evaluate_current_pose();
1007
1008        assert_vec3a_near(
1009            translation(runtime.world_matrices()[1]),
1010            Vec3A::new(0.0, 0.0, -1.0),
1011        );
1012    }
1013
1014    #[test]
1015    fn evaluates_current_pose_with_local_position_offset() {
1016        let model = Arc::new(
1017            ModelArena::new(vec![
1018                BoneInit::new(None, Vec3A::new(1.0, 0.0, 0.0)),
1019                BoneInit::new(Some(BoneIndex(0)), Vec3A::new(0.0, 2.0, 0.0)),
1020            ])
1021            .unwrap(),
1022        );
1023        let mut runtime = RuntimeInstance::new(model);
1024
1025        runtime
1026            .pose_mut()
1027            .set_local_position_offset(BoneIndex(1), Vec3A::new(0.0, 0.0, 3.0));
1028        runtime.evaluate_current_pose();
1029
1030        assert_vec3a_near(
1031            translation(runtime.world_matrices()[1]),
1032            Vec3A::new(1.0, 2.0, 3.0),
1033        );
1034    }
1035
1036    #[test]
1037    fn evaluates_clip_frame_into_world_matrices() {
1038        let model = Arc::new(
1039            ModelArena::new(vec![
1040                BoneInit::new(None, Vec3A::new(1.0, 0.0, 0.0)),
1041                BoneInit::new(Some(BoneIndex(0)), Vec3A::new(0.0, 2.0, 0.0)),
1042            ])
1043            .unwrap(),
1044        );
1045        let clip = AnimationClip::new(vec![BoneAnimationBinding {
1046            bone: BoneIndex(1),
1047            track: MovableBoneTrack::from_keyframes(vec![
1048                MovableBoneKeyframe::new(0, Vec3A::ZERO, Quat::IDENTITY),
1049                MovableBoneKeyframe::new(10, Vec3A::new(0.0, 0.0, 4.0), Quat::IDENTITY),
1050            ]),
1051        }]);
1052        let mut runtime = RuntimeInstance::new(model);
1053
1054        runtime.evaluate_clip_frame(&clip, 5.0);
1055
1056        assert_vec3a_near(
1057            translation(runtime.world_matrices()[1]),
1058            Vec3A::new(1.0, 2.0, 2.0),
1059        );
1060    }
1061
1062    #[test]
1063    fn applies_append_rotation_before_world_matrix_output() {
1064        let model = Arc::new(
1065            ModelArena::new_full(
1066                vec![
1067                    BoneInit::new(None, Vec3A::ZERO),
1068                    BoneInit::new(None, Vec3A::ZERO),
1069                    BoneInit::new(Some(BoneIndex(1)), Vec3A::new(1.0, 0.0, 0.0)),
1070                ],
1071                Vec::new(),
1072                vec![AppendTransformInit::new(BoneIndex(1), BoneIndex(0), 1.0).with_rotation()],
1073            )
1074            .unwrap(),
1075        );
1076        let mut runtime = RuntimeInstance::new(model);
1077
1078        runtime.pose_mut().set_local_rotation(
1079            BoneIndex(0),
1080            Quat::from_rotation_z(std::f32::consts::FRAC_PI_2),
1081        );
1082        runtime.evaluate_current_pose();
1083
1084        assert_vec3a_near(
1085            translation(runtime.world_matrices()[2]),
1086            Vec3A::new(0.0, 1.0, 0.0),
1087        );
1088    }
1089
1090    #[test]
1091    fn applies_append_translation_before_world_matrix_output() {
1092        let model = Arc::new(
1093            ModelArena::new_full(
1094                vec![
1095                    BoneInit::new(None, Vec3A::ZERO),
1096                    BoneInit::new(None, Vec3A::new(0.0, 1.0, 0.0)),
1097                ],
1098                Vec::new(),
1099                vec![AppendTransformInit::new(BoneIndex(1), BoneIndex(0), 0.5).with_translation()],
1100            )
1101            .unwrap(),
1102        );
1103        let mut runtime = RuntimeInstance::new(model);
1104
1105        runtime
1106            .pose_mut()
1107            .set_local_position_offset(BoneIndex(0), Vec3A::new(2.0, 0.0, 0.0));
1108        runtime.evaluate_current_pose();
1109
1110        assert_vec3a_near(
1111            translation(runtime.world_matrices()[1]),
1112            Vec3A::new(1.0, 1.0, 0.0),
1113        );
1114    }
1115
1116    #[test]
1117    fn initializes_ik_enabled_from_model_solvers() {
1118        let model = Arc::new(
1119            ModelArena::new_with_ik(
1120                vec![
1121                    BoneInit::new(None, Vec3A::ZERO),
1122                    BoneInit::new(Some(BoneIndex(0)), Vec3A::ZERO),
1123                ],
1124                vec![IkSolverInit::new(
1125                    BoneIndex(1),
1126                    BoneIndex(0),
1127                    vec![IkLinkInit::new(BoneIndex(0))],
1128                )],
1129            )
1130            .unwrap(),
1131        );
1132
1133        let runtime = RuntimeInstance::new(model);
1134
1135        assert_eq!(runtime.ik_enabled(), &[1]);
1136    }
1137
1138    #[test]
1139    fn solves_one_link_ik_toward_controller_bone() {
1140        let model = Arc::new(
1141            ModelArena::new_with_ik(
1142                vec![
1143                    BoneInit::new(None, Vec3A::ZERO),
1144                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(1.0, 0.0, 0.0)),
1145                    BoneInit::new(None, Vec3A::new(0.0, 1.0, 0.0)),
1146                ],
1147                vec![IkSolverInit {
1148                    ik_bone: BoneIndex(2),
1149                    target_bone: BoneIndex(1),
1150                    links: vec![IkLinkInit::new(BoneIndex(0))],
1151                    iteration_count: 1,
1152                    limit_angle: 0.0,
1153                }],
1154            )
1155            .unwrap(),
1156        );
1157        let mut runtime = RuntimeInstance::new(model);
1158
1159        runtime.evaluate_current_pose();
1160
1161        assert_vec3a_near(
1162            translation(runtime.world_matrices()[1]),
1163            Vec3A::new(0.0, 1.0, 0.0),
1164        );
1165    }
1166
1167    #[test]
1168    fn skips_disabled_ik_solver() {
1169        let model = Arc::new(
1170            ModelArena::new_with_ik(
1171                vec![
1172                    BoneInit::new(None, Vec3A::ZERO),
1173                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(1.0, 0.0, 0.0)),
1174                    BoneInit::new(None, Vec3A::new(0.0, 1.0, 0.0)),
1175                ],
1176                vec![IkSolverInit {
1177                    ik_bone: BoneIndex(2),
1178                    target_bone: BoneIndex(1),
1179                    links: vec![IkLinkInit::new(BoneIndex(0))],
1180                    iteration_count: 1,
1181                    limit_angle: 0.0,
1182                }],
1183            )
1184            .unwrap(),
1185        );
1186        let mut runtime = RuntimeInstance::new(model);
1187
1188        runtime.pose_mut().set_ik_enabled(0, false);
1189        runtime.evaluate_current_pose();
1190
1191        assert_vec3a_near(
1192            translation(runtime.world_matrices()[1]),
1193            Vec3A::new(1.0, 0.0, 0.0),
1194        );
1195    }
1196
1197    #[test]
1198    fn solves_two_link_ik_chain_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(Some(BoneIndex(1)), Vec3A::new(1.0, 0.0, 0.0)),
1205                    BoneInit::new(None, Vec3A::new(1.0, 1.0, 0.0)),
1206                ],
1207                vec![IkSolverInit {
1208                    ik_bone: BoneIndex(3),
1209                    target_bone: BoneIndex(2),
1210                    links: vec![IkLinkInit::new(BoneIndex(1)), IkLinkInit::new(BoneIndex(0))],
1211                    iteration_count: 4,
1212                    limit_angle: 0.0,
1213                }],
1214            )
1215            .unwrap(),
1216        );
1217        let mut runtime = RuntimeInstance::new(model);
1218
1219        runtime.evaluate_current_pose();
1220
1221        assert_vec3a_near(
1222            translation(runtime.world_matrices()[2]),
1223            Vec3A::new(1.0, 1.0, 0.0),
1224        );
1225    }
1226
1227    #[test]
1228    fn ik_updates_only_affected_eval_suffix_for_late_chain() {
1229        let unrelated_count = 96usize;
1230        let chain_root = BoneIndex(unrelated_count as u32);
1231        let chain_mid = BoneIndex(unrelated_count as u32 + 1);
1232        let chain_tip = BoneIndex(unrelated_count as u32 + 2);
1233        let controller = BoneIndex(unrelated_count as u32 + 3);
1234
1235        let mut bones = Vec::new();
1236        for i in 0..unrelated_count {
1237            bones.push(BoneInit::new(None, Vec3A::new(i as f32 * 10.0, -10.0, 0.0)));
1238        }
1239        bones.push(BoneInit::new(None, Vec3A::ZERO));
1240        bones.push(BoneInit::new(Some(chain_root), Vec3A::new(1.0, 0.0, 0.0)));
1241        bones.push(BoneInit::new(Some(chain_mid), Vec3A::new(1.0, 0.0, 0.0)));
1242        bones.push(BoneInit::new(None, Vec3A::new(1.0, 1.0, 0.0)));
1243
1244        let model = Arc::new(
1245            ModelArena::new_with_ik(
1246                bones,
1247                vec![IkSolverInit {
1248                    ik_bone: controller,
1249                    target_bone: chain_tip,
1250                    links: vec![IkLinkInit::new(chain_mid), IkLinkInit::new(chain_root)],
1251                    iteration_count: 4,
1252                    limit_angle: 0.0,
1253                }],
1254            )
1255            .unwrap(),
1256        );
1257        let mut runtime = RuntimeInstance::new(model);
1258
1259        runtime.reset_world_matrix_bone_update_count();
1260        runtime.evaluate_current_pose();
1261
1262        assert_vec3a_near(
1263            translation(runtime.world_matrices()[chain_tip.as_usize()]),
1264            Vec3A::new(1.0, 1.0, 0.0),
1265        );
1266        assert!(
1267            runtime.world_matrix_bone_update_count() < 250,
1268            "IK should not recompute unrelated prefix bones repeatedly; updated {} bones",
1269            runtime.world_matrix_bone_update_count()
1270        );
1271    }
1272
1273    #[test]
1274    fn clamps_ik_rotation_by_solver_limit_angle() {
1275        let model = Arc::new(
1276            ModelArena::new_with_ik(
1277                vec![
1278                    BoneInit::new(None, Vec3A::ZERO),
1279                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(1.0, 0.0, 0.0)),
1280                    BoneInit::new(None, Vec3A::new(0.0, 1.0, 0.0)),
1281                ],
1282                vec![IkSolverInit {
1283                    ik_bone: BoneIndex(2),
1284                    target_bone: BoneIndex(1),
1285                    links: vec![IkLinkInit::new(BoneIndex(0))],
1286                    iteration_count: 1,
1287                    limit_angle: std::f32::consts::FRAC_PI_4,
1288                }],
1289            )
1290            .unwrap(),
1291        );
1292        let mut runtime = RuntimeInstance::new(model);
1293
1294        runtime.evaluate_current_pose();
1295
1296        let expected = Vec3A::new(
1297            std::f32::consts::FRAC_1_SQRT_2,
1298            std::f32::consts::FRAC_1_SQRT_2,
1299            0.0,
1300        );
1301        assert_vec3a_near(translation(runtime.world_matrices()[1]), expected);
1302    }
1303
1304    #[test]
1305    fn applies_constant_limit_angle_per_iteration() {
1306        let model = Arc::new(
1307            ModelArena::new_with_ik(
1308                vec![
1309                    BoneInit::new(None, Vec3A::ZERO),
1310                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(1.0, 0.0, 0.0)),
1311                    BoneInit::new(None, Vec3A::new(0.0, 1.0, 0.0)),
1312                ],
1313                vec![IkSolverInit {
1314                    ik_bone: BoneIndex(2),
1315                    target_bone: BoneIndex(1),
1316                    links: vec![IkLinkInit::new(BoneIndex(1)), IkLinkInit::new(BoneIndex(0))],
1317                    iteration_count: 1,
1318                    limit_angle: std::f32::consts::FRAC_PI_4,
1319                }],
1320            )
1321            .unwrap(),
1322        );
1323        let mut runtime = RuntimeInstance::new(model);
1324
1325        runtime.evaluate_current_pose();
1326
1327        // With constant limit_angle = PI/4 (not scaled by link_index), only the root
1328        // (link 1, bone 0) rotates at most PI/4. The effector bone is skipped.
1329        // The child bone ends up at (cos(PI/4)*1, sin(PI/4)*1, 0)
1330        let expected = Vec3A::new(
1331            std::f32::consts::FRAC_1_SQRT_2,
1332            std::f32::consts::FRAC_1_SQRT_2,
1333            0.0,
1334        );
1335        assert_vec3a_near(translation(runtime.world_matrices()[1]), expected);
1336    }
1337
1338    #[test]
1339    fn clip_frame_produces_deterministic_world_translations() {
1340        let model = Arc::new(
1341            ModelArena::new(vec![
1342                BoneInit::new(None, Vec3A::new(1.0, 0.0, 0.0)),
1343                BoneInit::new(Some(BoneIndex(0)), Vec3A::new(0.0, 2.0, 0.0)),
1344            ])
1345            .unwrap(),
1346        );
1347        let clip = AnimationClip::new(vec![BoneAnimationBinding {
1348            bone: BoneIndex(1),
1349            track: MovableBoneTrack::from_keyframes(vec![
1350                MovableBoneKeyframe::new(0, Vec3A::ZERO, Quat::IDENTITY),
1351                MovableBoneKeyframe::new(10, Vec3A::new(0.0, 0.0, 4.0), Quat::IDENTITY),
1352            ]),
1353        }]);
1354        let mut runtime = RuntimeInstance::new(model);
1355
1356        runtime.evaluate_clip_frame(&clip, 5.0);
1357
1358        let matrices = runtime.world_matrices();
1359        assert_eq!(matrices.len(), 2);
1360        assert_vec3a_near(translation(matrices[0]), Vec3A::new(1.0, 0.0, 0.0));
1361        assert_vec3a_near(translation(matrices[1]), Vec3A::new(1.0, 2.0, 2.0));
1362    }
1363
1364    #[test]
1365    fn evaluate_clip_frame_without_ik_leaves_ik_unsolved() {
1366        let model = Arc::new(
1367            ModelArena::new_with_ik(
1368                vec![
1369                    BoneInit::new(None, Vec3A::ZERO),
1370                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(1.0, 0.0, 0.0)),
1371                    BoneInit::new(None, Vec3A::new(0.0, 1.0, 0.0)),
1372                ],
1373                vec![IkSolverInit {
1374                    ik_bone: BoneIndex(2),
1375                    target_bone: BoneIndex(1),
1376                    links: vec![IkLinkInit::new(BoneIndex(0))],
1377                    iteration_count: 1,
1378                    limit_angle: 0.0,
1379                }],
1380            )
1381            .unwrap(),
1382        );
1383        let clip = AnimationClip::new(vec![]);
1384
1385        let mut without_ik = RuntimeInstance::new(Arc::clone(&model));
1386        let mut with_ik = RuntimeInstance::new(model);
1387
1388        without_ik.evaluate_clip_frame_without_ik(&clip, 0.0);
1389        with_ik.evaluate_clip_frame(&clip, 0.0);
1390
1391        // Without IK: effector bone stays at rest position (1, 0, 0)
1392        assert_vec3a_near(
1393            translation(without_ik.world_matrices()[1]),
1394            Vec3A::new(1.0, 0.0, 0.0),
1395        );
1396        // With IK: effector bone rotates toward target at (0, 1, 0)
1397        assert_vec3a_near(
1398            translation(with_ik.world_matrices()[1]),
1399            Vec3A::new(0.0, 1.0, 0.0),
1400        );
1401    }
1402
1403    #[test]
1404    fn ik_options_cap_configured_iterations() {
1405        let model = Arc::new(
1406            ModelArena::new_with_ik(
1407                vec![
1408                    BoneInit::new(None, Vec3A::ZERO),
1409                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(1.0, 0.0, 0.0)),
1410                    BoneInit::new(None, Vec3A::new(0.0, 1.0, 0.0)),
1411                ],
1412                vec![IkSolverInit {
1413                    ik_bone: BoneIndex(2),
1414                    target_bone: BoneIndex(1),
1415                    links: vec![IkLinkInit::new(BoneIndex(0))],
1416                    iteration_count: 100,
1417                    limit_angle: 0.0,
1418                }],
1419            )
1420            .unwrap(),
1421        );
1422        let mut runtime = RuntimeInstance::new(model);
1423
1424        runtime.reset_ik_runtime_stats();
1425        runtime.evaluate_current_pose_with_ik_options(super::IkSolveOptions {
1426            tolerance: 0.0,
1427            max_iterations_cap: Some(5),
1428        });
1429
1430        assert_eq!(runtime.ik_runtime_stats()[0].configured_iterations, 5);
1431    }
1432
1433    // ---- morph expansion tests ----
1434
1435    fn assert_near(actual: f32, expected: f32) {
1436        let delta = (actual - expected).abs();
1437        assert!(
1438            delta < 1.0e-5,
1439            "actual={actual:?} expected={expected:?} delta={delta:?}"
1440        );
1441    }
1442
1443    #[test]
1444    fn bone_morph_position_offset_drives_world_position() {
1445        let model = Arc::new(
1446            ModelArena::new_with_morphs(
1447                vec![
1448                    BoneInit::new(None, Vec3A::ZERO),
1449                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(0.0, 1.0, 0.0)),
1450                ],
1451                Vec::new(),
1452                Vec::new(),
1453                crate::MorphInit {
1454                    morph_count: 1,
1455                    bone_offsets: vec![crate::BoneMorphOffset {
1456                        target_bone: BoneIndex(1),
1457                        position_offset: Vec3A::new(0.0, 0.0, 2.0),
1458                        rotation_offset: Quat::IDENTITY,
1459                    }],
1460                    bone_spans: vec![crate::MorphOffsetSpan { start: 0, count: 1 }],
1461                    group_offsets: vec![],
1462                    group_spans: vec![crate::MorphOffsetSpan::default()],
1463                    ..crate::MorphInit::default()
1464                },
1465            )
1466            .unwrap(),
1467        );
1468        let clip = AnimationClip::new_with_morphs(
1469            Vec::new(),
1470            vec![crate::MorphAnimationBinding {
1471                morph: crate::MorphIndex(0),
1472                track: crate::MorphTrack::from_keyframes(vec![
1473                    crate::MorphKeyframe::new(0, 0.0),
1474                    crate::MorphKeyframe::new(10, 1.0),
1475                ]),
1476            }],
1477        );
1478        let mut runtime = RuntimeInstance::new_with_morph_count(model, 1);
1479
1480        runtime.evaluate_clip_frame(&clip, 5.0);
1481
1482        // weight = 0.5: bone offset = (0,0,2) * 0.5 = (0,0,1)
1483        assert_vec3a_near(
1484            translation(runtime.world_matrices()[1]),
1485            Vec3A::new(0.0, 1.0, 1.0),
1486        );
1487        assert_near(runtime.morph_weights()[0], 0.5);
1488    }
1489
1490    #[test]
1491    fn bone_morph_rotation_offset_affects_child_position() {
1492        let model = Arc::new(
1493            ModelArena::new_with_morphs(
1494                vec![
1495                    BoneInit::new(None, Vec3A::ZERO),
1496                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(1.0, 0.0, 0.0)),
1497                    BoneInit::new(Some(BoneIndex(1)), Vec3A::new(1.0, 0.0, 0.0)),
1498                ],
1499                Vec::new(),
1500                Vec::new(),
1501                crate::MorphInit {
1502                    morph_count: 1,
1503                    bone_offsets: vec![crate::BoneMorphOffset {
1504                        target_bone: BoneIndex(1),
1505                        position_offset: Vec3A::ZERO,
1506                        rotation_offset: Quat::from_rotation_z(std::f32::consts::FRAC_PI_2),
1507                    }],
1508                    bone_spans: vec![crate::MorphOffsetSpan { start: 0, count: 1 }],
1509                    group_offsets: vec![],
1510                    group_spans: vec![crate::MorphOffsetSpan::default()],
1511                    ..crate::MorphInit::default()
1512                },
1513            )
1514            .unwrap(),
1515        );
1516        let clip = AnimationClip::new_with_morphs(
1517            Vec::new(),
1518            vec![crate::MorphAnimationBinding {
1519                morph: crate::MorphIndex(0),
1520                track: crate::MorphTrack::from_keyframes(vec![
1521                    crate::MorphKeyframe::new(0, 0.0),
1522                    crate::MorphKeyframe::new(10, 1.0),
1523                ]),
1524            }],
1525        );
1526        let mut runtime = RuntimeInstance::new_with_morph_count(model, 1);
1527
1528        runtime.evaluate_clip_frame(&clip, 10.0);
1529
1530        // weight = 1.0: bone 1 (rest 1,0,0) rotated Z-90 by morph (position unchanged)
1531        // bone 2 at (1,0,0) relative to bone 1: world = (1,0,0) + (0,1,0)
1532        assert_vec3a_near(
1533            translation(runtime.world_matrices()[2]),
1534            Vec3A::new(1.0, 1.0, 0.0),
1535        );
1536    }
1537
1538    #[test]
1539    fn group_morph_contributes_to_bone_morph_weight() {
1540        // PMX order: child (bone morph) has smaller index than parent (group morph)
1541        // Morph 0 = bone morph, Morph 1 = group morph with MorphIndex(0) as child.
1542        let model = Arc::new(
1543            ModelArena::new_with_morphs(
1544                vec![
1545                    BoneInit::new(None, Vec3A::ZERO),
1546                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(0.0, 1.0, 0.0)),
1547                ],
1548                Vec::new(),
1549                Vec::new(),
1550                crate::MorphInit {
1551                    morph_count: 2,
1552                    bone_offsets: vec![crate::BoneMorphOffset {
1553                        target_bone: BoneIndex(1),
1554                        position_offset: Vec3A::new(0.0, 0.0, 2.0),
1555                        rotation_offset: Quat::IDENTITY,
1556                    }],
1557                    bone_spans: vec![
1558                        crate::MorphOffsetSpan { start: 0, count: 1 },
1559                        crate::MorphOffsetSpan::default(),
1560                    ],
1561                    group_offsets: vec![crate::GroupMorphOffset {
1562                        child_morph: crate::MorphIndex(0),
1563                        ratio: 0.5,
1564                    }],
1565                    group_spans: vec![
1566                        crate::MorphOffsetSpan::default(),
1567                        crate::MorphOffsetSpan { start: 0, count: 1 },
1568                    ],
1569                    ..crate::MorphInit::default()
1570                },
1571            )
1572            .unwrap(),
1573        );
1574        // VMD track only on group morph (index 1), weight = 1.0
1575        let clip = AnimationClip::new_with_morphs(
1576            Vec::new(),
1577            vec![crate::MorphAnimationBinding {
1578                morph: crate::MorphIndex(1),
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, 2);
1586
1587        runtime.evaluate_clip_frame(&clip, 10.0);
1588
1589        // Group expansion: morph_weights[0] += 1.0 * 0.5 = 0.5
1590        // Bone morph applies: (0,0,2) * 0.5 = (0,0,1)
1591        assert_near(runtime.morph_weights()[0], 0.5);
1592        assert_near(runtime.morph_weights()[1], 1.0);
1593        assert_vec3a_near(
1594            translation(runtime.world_matrices()[1]),
1595            Vec3A::new(0.0, 1.0, 1.0),
1596        );
1597    }
1598
1599    #[test]
1600    fn group_morph_can_reference_later_child_morph() {
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::default(),
1618                        crate::MorphOffsetSpan { start: 0, count: 1 },
1619                    ],
1620                    group_offsets: vec![crate::GroupMorphOffset {
1621                        child_morph: crate::MorphIndex(1),
1622                        ratio: 0.5,
1623                    }],
1624                    group_spans: vec![
1625                        crate::MorphOffsetSpan { start: 0, count: 1 },
1626                        crate::MorphOffsetSpan::default(),
1627                    ],
1628                    ..crate::MorphInit::default()
1629                },
1630            )
1631            .unwrap(),
1632        );
1633        let clip = AnimationClip::new_with_morphs(
1634            Vec::new(),
1635            vec![crate::MorphAnimationBinding {
1636                morph: crate::MorphIndex(0),
1637                track: crate::MorphTrack::from_keyframes(vec![crate::MorphKeyframe::new(0, 1.0)]),
1638            }],
1639        );
1640        let mut runtime = RuntimeInstance::new(model);
1641
1642        runtime.evaluate_clip_frame(&clip, 0.0);
1643
1644        assert_near(runtime.morph_weights()[0], 1.0);
1645        assert_near(runtime.morph_weights()[1], 0.5);
1646        assert_vec3a_near(
1647            translation(runtime.world_matrices()[1]),
1648            Vec3A::new(0.0, 1.0, 1.0),
1649        );
1650    }
1651
1652    #[test]
1653    fn chained_group_morphs_descend_to_bone_morph_weight() {
1654        let model = Arc::new(
1655            ModelArena::new_with_morphs(
1656                vec![
1657                    BoneInit::new(None, Vec3A::ZERO),
1658                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(0.0, 1.0, 0.0)),
1659                ],
1660                Vec::new(),
1661                Vec::new(),
1662                crate::MorphInit {
1663                    morph_count: 3,
1664                    bone_offsets: vec![crate::BoneMorphOffset {
1665                        target_bone: BoneIndex(1),
1666                        position_offset: Vec3A::new(0.0, 0.0, 2.0),
1667                        rotation_offset: Quat::IDENTITY,
1668                    }],
1669                    bone_spans: vec![
1670                        crate::MorphOffsetSpan { start: 0, count: 1 },
1671                        crate::MorphOffsetSpan::default(),
1672                        crate::MorphOffsetSpan::default(),
1673                    ],
1674                    group_offsets: vec![
1675                        crate::GroupMorphOffset {
1676                            child_morph: crate::MorphIndex(0),
1677                            ratio: 0.25,
1678                        },
1679                        crate::GroupMorphOffset {
1680                            child_morph: crate::MorphIndex(1),
1681                            ratio: 0.5,
1682                        },
1683                    ],
1684                    group_spans: vec![
1685                        crate::MorphOffsetSpan::default(),
1686                        crate::MorphOffsetSpan { start: 0, count: 1 },
1687                        crate::MorphOffsetSpan { start: 1, count: 1 },
1688                    ],
1689                    ..crate::MorphInit::default()
1690                },
1691            )
1692            .unwrap(),
1693        );
1694        let clip = AnimationClip::new_with_morphs(
1695            Vec::new(),
1696            vec![crate::MorphAnimationBinding {
1697                morph: crate::MorphIndex(2),
1698                track: crate::MorphTrack::from_keyframes(vec![crate::MorphKeyframe::new(0, 1.0)]),
1699            }],
1700        );
1701        let mut runtime = RuntimeInstance::new(model);
1702
1703        runtime.evaluate_clip_frame(&clip, 0.0);
1704
1705        assert_near(runtime.morph_weights()[2], 1.0);
1706        assert_near(runtime.morph_weights()[1], 0.5);
1707        assert_near(runtime.morph_weights()[0], 0.125);
1708        assert_vec3a_near(
1709            translation(runtime.world_matrices()[1]),
1710            Vec3A::new(0.0, 1.0, 0.25),
1711        );
1712    }
1713
1714    #[test]
1715    fn expand_morphs_noop_when_no_morph_defs() {
1716        let model = Arc::new(ModelArena::new(vec![BoneInit::new(None, Vec3A::ZERO)]).unwrap());
1717        let mut runtime = RuntimeInstance::new_with_morph_count(model, 1);
1718        runtime
1719            .pose_mut()
1720            .set_morph_weight(crate::MorphIndex(0), 1.0);
1721        runtime.expand_morphs();
1722        // No crash = pass
1723        assert_near(runtime.morph_weights()[0], 1.0);
1724    }
1725
1726    #[test]
1727    fn clamps_link_local_rotation_to_angle_limit() {
1728        let model = Arc::new(
1729            ModelArena::new_with_ik(
1730                vec![
1731                    BoneInit::new(None, Vec3A::ZERO),
1732                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(1.0, 0.0, 0.0)),
1733                    BoneInit::new(None, Vec3A::new(0.0, 1.0, 0.0)),
1734                ],
1735                vec![IkSolverInit {
1736                    ik_bone: BoneIndex(2),
1737                    target_bone: BoneIndex(1),
1738                    links: vec![
1739                        IkLinkInit::new(BoneIndex(0)).with_angle_limit(IkAngleLimit::new(
1740                            Vec3A::new(0.0, 0.0, 0.0),
1741                            Vec3A::new(0.0, 0.0, std::f32::consts::FRAC_PI_4),
1742                        )),
1743                    ],
1744                    iteration_count: 1,
1745                    limit_angle: 0.0,
1746                }],
1747            )
1748            .unwrap(),
1749        );
1750        let mut runtime = RuntimeInstance::new(model);
1751
1752        runtime.evaluate_current_pose();
1753
1754        let expected = Vec3A::new(
1755            std::f32::consts::FRAC_1_SQRT_2,
1756            std::f32::consts::FRAC_1_SQRT_2,
1757            0.0,
1758        );
1759        assert_vec3a_near(translation(runtime.world_matrices()[1]), expected);
1760    }
1761
1762    #[test]
1763    fn plane_link_step_preserves_base_rotation() {
1764        let base = Quat::from_rotation_x(0.3);
1765        let base_rotations = vec![base];
1766        let mut ik_rotations = vec![Quat::IDENTITY];
1767        let mut chain_states = vec![super::ChainLinkState {
1768            previous_euler: [0.0; 3],
1769            plane_mode_angle: 0.0,
1770        }];
1771        let local_effector = Vec3A::X;
1772        let local_target = Vec3A::Y;
1773
1774        super::solve_plane_link_step(super::PlaneLinkStepInput {
1775            local_effector: &local_effector,
1776            local_target: &local_target,
1777            link_index: 0,
1778            base_rotations: &base_rotations,
1779            ik_rotations: &mut ik_rotations,
1780            chain_states: &mut chain_states,
1781            axis_index: 2,
1782            limits: IkAngleLimit::new(
1783                Vec3A::new(-std::f32::consts::PI, 0.0, -std::f32::consts::PI),
1784                Vec3A::new(std::f32::consts::PI, 0.0, std::f32::consts::PI),
1785            ),
1786            iteration: 0,
1787            limit_angle: 0.0,
1788        });
1789
1790        let effective = (ik_rotations[0] * base_rotations[0]).normalize();
1791        assert_vec3a_near(effective.mul_vec3a(Vec3A::Z), base.mul_vec3a(Vec3A::Z));
1792    }
1793
1794    #[test]
1795    fn append_rotation_propagates_post_ik_link_rotation() {
1796        let model = Arc::new(
1797            ModelArena::new_full(
1798                vec![
1799                    BoneInit::new(None, Vec3A::ZERO),
1800                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(1.0, 0.0, 0.0)),
1801                    BoneInit::new(None, Vec3A::new(0.0, 1.0, 0.0)),
1802                    BoneInit::new(None, Vec3A::ZERO),
1803                    BoneInit::new(Some(BoneIndex(3)), Vec3A::new(1.0, 0.0, 0.0)),
1804                ],
1805                vec![IkSolverInit {
1806                    ik_bone: BoneIndex(2),
1807                    target_bone: BoneIndex(1),
1808                    links: vec![IkLinkInit::new(BoneIndex(0))],
1809                    iteration_count: 1,
1810                    limit_angle: 0.0,
1811                }],
1812                vec![AppendTransformInit::new(BoneIndex(3), BoneIndex(0), 1.0).with_rotation()],
1813            )
1814            .unwrap(),
1815        );
1816        let mut runtime = RuntimeInstance::new(model);
1817
1818        runtime.evaluate_current_pose();
1819
1820        assert_vec3a_near(
1821            translation(runtime.world_matrices()[4]),
1822            Vec3A::new(0.0, 1.0, 0.0),
1823        );
1824    }
1825
1826    #[test]
1827    fn scratch_ik_capacities_stable_after_repeated_evaluate() {
1828        let model = Arc::new(
1829            ModelArena::new_with_ik(
1830                vec![
1831                    BoneInit::new(None, Vec3A::ZERO),
1832                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(1.0, 0.0, 0.0)),
1833                    BoneInit::new(None, Vec3A::new(0.0, 1.0, 0.0)),
1834                    BoneInit::new(None, Vec3A::ZERO),
1835                    BoneInit::new(Some(BoneIndex(3)), Vec3A::new(1.0, 0.0, 0.0)),
1836                    BoneInit::new(Some(BoneIndex(4)), Vec3A::new(1.0, 0.0, 0.0)),
1837                    BoneInit::new(None, Vec3A::new(0.0, 1.0, 0.0)),
1838                ],
1839                vec![
1840                    IkSolverInit {
1841                        ik_bone: BoneIndex(2),
1842                        target_bone: BoneIndex(1),
1843                        links: vec![IkLinkInit::new(BoneIndex(0))],
1844                        iteration_count: 1,
1845                        limit_angle: 0.0,
1846                    },
1847                    IkSolverInit {
1848                        ik_bone: BoneIndex(6),
1849                        target_bone: BoneIndex(5),
1850                        links: vec![IkLinkInit::new(BoneIndex(3)), IkLinkInit::new(BoneIndex(4))],
1851                        iteration_count: 1,
1852                        limit_angle: 0.0,
1853                    },
1854                ],
1855            )
1856            .unwrap(),
1857        );
1858        let mut runtime = RuntimeInstance::new(model);
1859
1860        runtime.evaluate_current_pose();
1861
1862        let cap_links = runtime.ik_scratch.links.capacity();
1863        let cap_base = runtime.ik_scratch.base_rotations.capacity();
1864        let cap_ik = runtime.ik_scratch.ik_rotations.capacity();
1865        let cap_best = runtime.ik_scratch.best_ik_rotations.capacity();
1866        let cap_chain = runtime.ik_scratch.chain_states.capacity();
1867
1868        for _ in 0..10 {
1869            runtime.evaluate_current_pose();
1870        }
1871
1872        assert_eq!(runtime.ik_scratch.links.capacity(), cap_links);
1873        assert_eq!(runtime.ik_scratch.base_rotations.capacity(), cap_base);
1874        assert_eq!(runtime.ik_scratch.ik_rotations.capacity(), cap_ik);
1875        assert_eq!(runtime.ik_scratch.best_ik_rotations.capacity(), cap_best);
1876        assert_eq!(runtime.ik_scratch.chain_states.capacity(), cap_chain);
1877    }
1878
1879    #[test]
1880    fn scratch_morph_capacity_stable_after_repeated_clip_frame() {
1881        let model = Arc::new(
1882            ModelArena::new_with_morphs(
1883                vec![
1884                    BoneInit::new(None, Vec3A::ZERO),
1885                    BoneInit::new(Some(BoneIndex(0)), Vec3A::new(0.0, 1.0, 0.0)),
1886                ],
1887                Vec::new(),
1888                Vec::new(),
1889                crate::MorphInit {
1890                    morph_count: 2,
1891                    bone_offsets: vec![crate::BoneMorphOffset {
1892                        target_bone: BoneIndex(1),
1893                        position_offset: Vec3A::new(0.0, 0.0, 2.0),
1894                        rotation_offset: Quat::IDENTITY,
1895                    }],
1896                    bone_spans: vec![
1897                        crate::MorphOffsetSpan { start: 0, count: 1 },
1898                        crate::MorphOffsetSpan::default(),
1899                    ],
1900                    group_offsets: vec![crate::GroupMorphOffset {
1901                        child_morph: crate::MorphIndex(0),
1902                        ratio: 0.5,
1903                    }],
1904                    group_spans: vec![
1905                        crate::MorphOffsetSpan::default(),
1906                        crate::MorphOffsetSpan { start: 0, count: 1 },
1907                    ],
1908                    ..crate::MorphInit::default()
1909                },
1910            )
1911            .unwrap(),
1912        );
1913        let clip = AnimationClip::new_with_morphs(
1914            Vec::new(),
1915            vec![crate::MorphAnimationBinding {
1916                morph: crate::MorphIndex(1),
1917                track: crate::MorphTrack::from_keyframes(vec![
1918                    crate::MorphKeyframe::new(0, 0.0),
1919                    crate::MorphKeyframe::new(10, 1.0),
1920                ]),
1921            }],
1922        );
1923        let mut runtime = RuntimeInstance::new_with_morph_count(model, 2);
1924
1925        runtime.evaluate_clip_frame(&clip, 5.0);
1926
1927        let cap_expanded = runtime.morph_scratch.expanded_weights.capacity();
1928
1929        for _ in 0..10 {
1930            runtime.evaluate_clip_frame(&clip, 5.0);
1931        }
1932
1933        assert_eq!(
1934            runtime.morph_scratch.expanded_weights.capacity(),
1935            cap_expanded
1936        );
1937    }
1938}