Skip to main content

mittens_engine/engine/ecs/system/
ik_system.rs

1use crate::engine::ecs::component::ik_chain::TwoBoneIkDebugVisuals;
2use crate::engine::ecs::component::{
3    AvatarControlComponent, BoneRestPoseComponent, ColorComponent, EmissiveComponent,
4    IKChainComponent, IKSolver, OverlayComponent, QueryRootMode, RenderableComponent,
5    TransformComponent, resolve_component_ref,
6};
7use crate::engine::ecs::{ComponentId, IntentValue, SignalEmitter, World};
8use crate::utils::math::{
9    mat_to_quat, mat4_inverse, quat_conjugate, quat_from_axis_angle, quat_mul, quat_nlerp,
10    quat_rotate_vec3, quat_rotation_y, quat_to_axis_angle, shortest_arc_quat, vec3_add, vec3_cross,
11    vec3_dot, vec3_len, vec3_lerp, vec3_normalize, vec3_scale, vec3_sub,
12};
13use std::collections::HashSet;
14
15#[derive(Debug, Default)]
16pub struct IKSystem {
17    chains: HashSet<ComponentId>,
18}
19
20impl IKSystem {
21    pub fn new() -> Self {
22        Self::default()
23    }
24
25    pub fn tick(&mut self, world: &mut World, emit: &mut dyn SignalEmitter, _dt_sec: f32) {
26        let ids: Vec<_> = self.chains.iter().copied().collect();
27        for id in ids {
28            // Lazy resolution of `target_source` / `end_effector_source` into
29            // the actual ComponentId fields. Matches AnimationSystem's
30            // behavior for ActionComponent and supports forward refs
31            // (sources authored before the referent exists). No-op when the
32            // ids are already filled (either by registry-time resolve at
33            // call-construction, or by a previous tick).
34            resolve_ik_chain_refs(world, id);
35            resolve_avc_ancestor(world, id);
36            tick_chain(id, world, emit);
37        }
38    }
39
40    pub fn register(&mut self, component: ComponentId) {
41        self.chains.insert(component);
42    }
43
44    pub fn remove(&mut self, component: ComponentId) {
45        self.chains.remove(&component);
46    }
47}
48
49/// Best-effort resolve of IKChain's `target_source` / `end_effector_source`
50/// into the matching `target_id` / `end_effector_id` slots when the latter
51/// are null. Silent no-op on miss — the IK solver short-circuits on a null
52/// target anyway, so a still-unresolved chain just skips that tick. A future
53/// tick may succeed once the referent spawns.
54fn resolve_ik_chain_refs(world: &mut World, id: ComponentId) {
55    use crate::engine::ecs::component::ComponentRef;
56    use slotmap::Key;
57    let (target_src, end_src, target_id, end_id) = {
58        let Some(ik) = world.get_component_by_id_as::<IKChainComponent>(id) else {
59            return;
60        };
61        (
62            ik.target_source.clone(),
63            ik.end_effector_source.clone(),
64            ik.target_id,
65            ik.end_effector_id,
66        )
67    };
68
69    let resolve = |src: &ComponentRef| -> Option<ComponentId> {
70        resolve_component_ref(world, src, Some(id), QueryRootMode::SelfSubtree)
71    };
72
73    let new_target = if target_id.is_null() {
74        target_src.as_ref().and_then(resolve)
75    } else {
76        None
77    };
78    let new_end = if end_id.is_null() {
79        end_src.as_ref().and_then(resolve)
80    } else {
81        None
82    };
83
84    if new_target.is_none() && new_end.is_none() {
85        return;
86    }
87    if let Some(ik) = world.get_component_by_id_as_mut::<IKChainComponent>(id) {
88        if let Some(t) = new_target {
89            ik.target_id = t;
90        }
91        if let Some(e) = new_end {
92            ik.end_effector_id = e;
93        }
94    }
95}
96
97/// One-time ancestor walk: find the nearest `AvatarControlComponent` above
98/// this IKChainComponent and cache it.  No-op once cached.
99fn resolve_avc_ancestor(world: &mut World, id: ComponentId) {
100    let needs_walk = world
101        .get_component_by_id_as::<IKChainComponent>(id)
102        .map(|c| c.avc_id.is_none())
103        .unwrap_or(false);
104    if !needs_walk {
105        return;
106    }
107    let found = find_avc_ancestor(world, id);
108    if let Some(ik) = world.get_component_by_id_as_mut::<IKChainComponent>(id) {
109        ik.avc_id = found;
110    }
111}
112
113/// Walk up the parent chain from `id` to find the nearest ancestor
114/// `AvatarControlComponent`.  Returns `None` if no AVC is found within
115/// 32 hops.
116fn find_avc_ancestor(world: &World, id: ComponentId) -> Option<ComponentId> {
117    let mut cur = id;
118    for _ in 0..32 {
119        let parent = world.parent_of(cur)?;
120        if world
121            .get_component_by_id_as::<AvatarControlComponent>(parent)
122            .is_some()
123        {
124            return Some(parent);
125        }
126        cur = parent;
127    }
128    None
129}
130
131fn tick_chain(id: ComponentId, world: &mut World, emit: &mut dyn SignalEmitter) {
132    let (solver, target_id, end_effector_id, weight, avc_id, xr_pose_driver) = {
133        let Some(c) = world.get_component_by_id_as::<IKChainComponent>(id) else {
134            return;
135        };
136        (
137            c.solver,
138            c.target_id,
139            c.end_effector_id,
140            c.weight,
141            c.avc_id,
142            c.xr_pose_driver,
143        )
144    };
145    if weight <= 0.0 {
146        return;
147    }
148    if let Some(driver) = xr_pose_driver {
149        let valid = world
150            .get_component_by_id_as::<crate::engine::ecs::component::InputXRComponent>(driver)
151            .map(|component| component.pose_valid)
152            .or_else(|| {
153                world
154                    .get_component_by_id_as::<crate::engine::ecs::component::ControllerXRComponent>(
155                        driver,
156                    )
157                    .map(|component| component.pose_valid)
158            })
159            .unwrap_or(false);
160        if !valid {
161            return;
162        }
163    }
164
165    // For AimConstraint / Fabrik, root joint TC = parent of IKChainComponent.
166    // TwoBoneIK ignores this and uses the explicit joint IDs on the variant.
167    let root_tc_opt = world.parent_of(id).filter(|&p| {
168        world
169            .get_component_by_id_as::<TransformComponent>(p)
170            .is_some()
171    });
172
173    match solver {
174        IKSolver::AimConstraint {
175            offset_yaw,
176            copy_position,
177            target_position_offset,
178        } => {
179            let Some(root_tc) = root_tc_opt else { return };
180            solve_aim(
181                world,
182                emit,
183                root_tc,
184                target_id,
185                offset_yaw,
186                copy_position,
187                target_position_offset,
188                weight,
189            );
190        }
191        IKSolver::TwoBoneIK {
192            root_joint_id,
193            mid_joint_id,
194            pole_direction,
195            copy_end_rotation,
196        } => {
197            // Explicit 3-node chain — no topology discovery. Sibling helper /
198            // collider / cloth nodes under the arm bones are ignored.
199            use slotmap::Key;
200            if root_joint_id.is_null() || mid_joint_id.is_null() || end_effector_id.is_null() {
201                return;
202            }
203            if world
204                .get_component_by_id_as::<TransformComponent>(root_joint_id)
205                .is_none()
206                || world
207                    .get_component_by_id_as::<TransformComponent>(mid_joint_id)
208                    .is_none()
209                || world
210                    .get_component_by_id_as::<TransformComponent>(end_effector_id)
211                    .is_none()
212            {
213                return;
214            }
215            let chain = [root_joint_id, mid_joint_id, end_effector_id];
216            solve_two_bone(
217                world,
218                emit,
219                id,
220                &chain,
221                target_id,
222                pole_direction,
223                copy_end_rotation,
224                weight,
225                avc_id,
226            );
227        }
228        IKSolver::Fabrik {
229            max_iterations,
230            tolerance,
231            target_position_offset,
232        } => {
233            let Some(root_tc) = root_tc_opt else { return };
234            let chain = collect_tc_chain(world, root_tc, end_effector_id);
235            if chain.len() < 2 {
236                return;
237            }
238            solve_fabrik(
239                world,
240                emit,
241                &chain,
242                target_id,
243                target_position_offset,
244                max_iterations,
245                tolerance,
246                weight,
247            );
248        }
249    }
250}
251
252// ---------------------------------------------------------------------------
253// Chain collection
254// ---------------------------------------------------------------------------
255
256/// Walk UP from `end_id` via TC parents until reaching `root`.  Returns the
257/// collected IDs in root-to-end order.
258///
259/// Walking up is unique (each TC has one parent) so this picks out a single
260/// topological path even when intermediate joints have multiple TC children
261/// (e.g. the spine fork into clavicles).  Returns empty if `end_id` is not
262/// a TC descendant of `root` within 32 hops.
263fn collect_tc_chain(world: &World, root: ComponentId, end_id: ComponentId) -> Vec<ComponentId> {
264    if root == end_id {
265        return vec![root];
266    }
267    let mut up: Vec<ComponentId> = vec![end_id];
268    let mut cur = end_id;
269    for _ in 0..32 {
270        let Some(parent) = world.parent_of(cur) else {
271            return Vec::new();
272        };
273        if world
274            .get_component_by_id_as::<TransformComponent>(parent)
275            .is_none()
276        {
277            return Vec::new();
278        }
279        up.push(parent);
280        if parent == root {
281            up.reverse();
282            return up;
283        }
284        cur = parent;
285    }
286    Vec::new()
287}
288
289// ---------------------------------------------------------------------------
290// AimConstraint solver
291// ---------------------------------------------------------------------------
292
293/// Orient `root_tc` so its world rotation matches `target_id`'s world rotation
294/// post-multiplied by `rot_y(offset_yaw)`.  Only modifies rotation; preserves
295/// existing local translation and scale.
296fn solve_aim(
297    world: &World,
298    emit: &mut dyn SignalEmitter,
299    root_tc: ComponentId,
300    target_id: ComponentId,
301    offset_yaw: f32,
302    copy_position: bool,
303    target_position_offset: [f32; 3],
304    weight: f32,
305) {
306    let Some(target_tc) = world.get_component_by_id_as::<TransformComponent>(target_id) else {
307        return;
308    };
309    let target_world_rot = mat_to_quat(target_tc.transform.matrix_world);
310    let desired_world_rot = quat_mul(target_world_rot, quat_rotation_y(offset_yaw));
311    // Apply the offset in TARGET local frame, then add to target world position.
312    // For an HMD target with offset = (0, -eye_height, 0), this drops the bone target
313    // down along the HMD's local Y so the eye mesh (above the bone pivot) lines up
314    // with the HMD position.
315    let target_local_offset_world = quat_rotate_vec3(target_world_rot, target_position_offset);
316    let target_world_pos = [
317        target_tc.transform.matrix_world[3][0] + target_local_offset_world[0],
318        target_tc.transform.matrix_world[3][1] + target_local_offset_world[1],
319        target_tc.transform.matrix_world[3][2] + target_local_offset_world[2],
320    ];
321
322    let parent_world_mat = world
323        .parent_of(root_tc)
324        .and_then(|p| world.get_component_by_id_as::<TransformComponent>(p))
325        .map(|t| t.transform.matrix_world)
326        .unwrap_or([
327            [1.0, 0.0, 0.0, 0.0],
328            [0.0, 1.0, 0.0, 0.0],
329            [0.0, 0.0, 1.0, 0.0],
330            [0.0, 0.0, 0.0, 1.0],
331        ]);
332    let parent_world_rot = mat_to_quat(parent_world_mat);
333
334    let full_local_rot = quat_mul(quat_conjugate(parent_world_rot), desired_world_rot);
335
336    let local_rot = if weight < 1.0 {
337        let cur = world
338            .get_component_by_id_as::<TransformComponent>(root_tc)
339            .map(|t| t.transform.rotation)
340            .unwrap_or([0.0, 0.0, 0.0, 1.0]);
341        quat_nlerp(cur, full_local_rot, weight)
342    } else {
343        full_local_rot
344    };
345
346    let (cur_t, s) = world
347        .get_component_by_id_as::<TransformComponent>(root_tc)
348        .map(|tc| (tc.transform.translation, tc.transform.scale))
349        .unwrap_or(([0.0; 3], [1.0, 1.0, 1.0]));
350
351    let local_t = if copy_position {
352        // Invert parent world matrix to get local position from target world position.
353        // Closed-form inverse of an affine TRS matrix: local_pos = R^T * (target_pos - parent_pos) / parent_scale.
354        // Easier: use the inverse-transpose of the 3x3 rotation+scale block, then translate.
355        let parent_pos = [
356            parent_world_mat[3][0],
357            parent_world_mat[3][1],
358            parent_world_mat[3][2],
359        ];
360        let delta = [
361            target_world_pos[0] - parent_pos[0],
362            target_world_pos[1] - parent_pos[1],
363            target_world_pos[2] - parent_pos[2],
364        ];
365        // Apply inverse parent rotation (assuming uniform scale; for non-uniform scale this would be approximate).
366        let inv_parent_rot = quat_conjugate(parent_world_rot);
367        let local_pos = quat_rotate_vec3(inv_parent_rot, delta);
368        if weight < 1.0 {
369            [
370                cur_t[0] + (local_pos[0] - cur_t[0]) * weight,
371                cur_t[1] + (local_pos[1] - cur_t[1]) * weight,
372                cur_t[2] + (local_pos[2] - cur_t[2]) * weight,
373            ]
374        } else {
375            local_pos
376        }
377    } else {
378        cur_t
379    };
380
381    emit.push_intent_now(
382        root_tc,
383        IntentValue::UpdateTransform {
384            component_ids: vec![root_tc],
385            translation: local_t,
386            rotation_quat_xyzw: local_rot,
387            scale: s,
388        },
389    );
390}
391
392// ---------------------------------------------------------------------------
393// TwoBoneIK solver
394// ---------------------------------------------------------------------------
395
396/// Closed-form 2-bone IK.
397///
398/// `chain` must have length ≥ 3: [root (upper arm), mid (lower arm), end (hand)].
399/// `target_id`: TC providing the desired hand world position.
400/// `pole_direction`: direction hint for the elbow.  Interpreted in body-local
401///   space when `avc_id` is `Some` (transformed to world via model root rotation);
402///   world-space when `avc_id` is `None`.
403/// `copy_end_rotation`: if true, also aligns the end bone to the target's world rotation.
404/// `avc_id`: cached ancestor `AvatarControlComponent` id, or `None` for world-space pole.
405fn solve_two_bone(
406    world: &mut World,
407    emit: &mut dyn SignalEmitter,
408    ik_chain_id: ComponentId,
409    chain: &[ComponentId],
410    target_id: ComponentId,
411    pole_direction: [f32; 3],
412    copy_end_rotation: bool,
413    weight: f32,
414    avc_id: Option<ComponentId>,
415) {
416    let (root_tc, mid_tc, end_tc) = (chain[0], chain[1], chain[2]);
417
418    // FK world positions — bone lengths are measured here each tick.
419    let root_pos = tc_world_pos(world, root_tc);
420    let mid_pos = tc_world_pos(world, mid_tc);
421    let end_pos = tc_world_pos(world, end_tc);
422    let target_pos = tc_world_pos(world, target_id);
423
424    let root_world_rot = tc_world_rot(world, root_tc);
425    let mid_world_rot = tc_world_rot(world, mid_tc);
426
427    let upper_len = vec3_len(vec3_sub(mid_pos, root_pos)).max(1e-6);
428    let lower_len = vec3_len(vec3_sub(end_pos, mid_pos)).max(1e-6);
429
430    let root_parent_world_rot = world
431        .parent_of(root_tc)
432        .and_then(|p| world.get_component_by_id_as::<TransformComponent>(p))
433        .map(|t| mat_to_quat(t.transform.matrix_world))
434        .unwrap_or([0.0, 0.0, 0.0, 1.0]);
435
436    // Triangle solve — clamp reach to avoid degenerate case beyond full extension.
437    let to_target = vec3_sub(target_pos, root_pos);
438    let raw_reach = vec3_len(to_target);
439    let reach = raw_reach.min(upper_len + lower_len - 1e-4).max(1e-6);
440    let reach_dir = if raw_reach > 1e-6 {
441        vec3_scale(to_target, 1.0 / raw_reach)
442    } else {
443        [0.0, 1.0, 0.0]
444    };
445
446    let cos_upper = ((upper_len * upper_len + reach * reach - lower_len * lower_len)
447        / (2.0 * upper_len * reach))
448        .clamp(-1.0, 1.0);
449    let upper_angle = cos_upper.acos();
450
451    // Build elbow plane from pole hint.
452    // Transform body-local pole to world space when under an AVC.
453    let pole = match avc_id {
454        Some(avc) => world
455            .get_component_by_id_as::<AvatarControlComponent>(avc)
456            .and_then(|c| c.model_root_id)
457            .map(|root_id| {
458                let root_rot = tc_world_rot(world, root_id);
459                quat_rotate_vec3(root_rot, pole_direction)
460            })
461            .unwrap_or(pole_direction),
462        None => pole_direction,
463    };
464    let cross_tp = vec3_cross(to_target, pole);
465    let plane_normal = if vec3_len(cross_tp) > 1e-6 {
466        vec3_normalize(cross_tp)
467    } else {
468        let fallback = if reach_dir[0].abs() < 0.9 {
469            [1.0, 0.0, 0.0]
470        } else {
471            [0.0, 1.0, 0.0]
472        };
473        vec3_normalize(vec3_cross(to_target, fallback))
474    };
475    let perp = vec3_normalize(vec3_cross(plane_normal, reach_dir));
476
477    let elbow_dir = vec3_normalize(vec3_add(
478        vec3_scale(reach_dir, upper_angle.cos()),
479        vec3_scale(perp, upper_angle.sin()),
480    ));
481    let elbow_pos = vec3_add(root_pos, vec3_scale(elbow_dir, upper_len));
482
483    maybe_update_two_bone_debug(
484        world,
485        emit,
486        ik_chain_id,
487        avc_id,
488        root_pos,
489        target_pos,
490        pole,
491        plane_normal,
492        elbow_pos,
493    );
494
495    // --- Upper arm ---
496    let old_upper_fwd = if vec3_len(vec3_sub(mid_pos, root_pos)) > 1e-6 {
497        vec3_normalize(vec3_sub(mid_pos, root_pos))
498    } else {
499        [0.0, 0.0, 1.0]
500    };
501    let delta_upper = shortest_arc_quat(old_upper_fwd, elbow_dir);
502    let new_upper_world_rot = quat_mul(delta_upper, root_world_rot);
503
504    let full_upper_local = quat_mul(quat_conjugate(root_parent_world_rot), new_upper_world_rot);
505    let upper_local = if weight < 1.0 {
506        let cur = world
507            .get_component_by_id_as::<TransformComponent>(root_tc)
508            .map(|t| t.transform.rotation)
509            .unwrap_or([0.0, 0.0, 0.0, 1.0]);
510        quat_nlerp(cur, full_upper_local, weight)
511    } else {
512        full_upper_local
513    };
514
515    // --- Lower arm ---
516    let old_lower_fwd = if vec3_len(vec3_sub(end_pos, mid_pos)) > 1e-6 {
517        vec3_normalize(vec3_sub(end_pos, mid_pos))
518    } else {
519        [0.0, 0.0, 1.0]
520    };
521    // After the upper arm delta, the lower arm's FK forward rotates by delta_upper too.
522    let lower_fwd_after_upper = quat_rotate_vec3(delta_upper, old_lower_fwd);
523    let new_lower_fwd = if vec3_len(vec3_sub(target_pos, elbow_pos)) > 1e-6 {
524        vec3_normalize(vec3_sub(target_pos, elbow_pos))
525    } else {
526        lower_fwd_after_upper
527    };
528    let delta_lower = shortest_arc_quat(lower_fwd_after_upper, new_lower_fwd);
529    let mut new_lower_world_rot = quat_mul(delta_lower, quat_mul(delta_upper, mid_world_rot));
530    let end_local_rest = read_bone_rest_rot(world, end_tc);
531    let solved_forearm_axis = if vec3_len(vec3_sub(target_pos, elbow_pos)) > 1e-6 {
532        vec3_normalize(vec3_sub(target_pos, elbow_pos))
533    } else {
534        new_lower_fwd
535    };
536    let solved_hand_world_rot = quat_mul(new_lower_world_rot, end_local_rest);
537    let target_world_rot = tc_world_rot(world, target_id);
538    let target_from_solved_hand = quat_mul(target_world_rot, quat_conjugate(solved_hand_world_rot));
539    let forearm_twist_world =
540        extract_twist_about_axis(target_from_solved_hand, solved_forearm_axis);
541    let (_, forearm_twist_angle) = quat_to_axis_angle(forearm_twist_world);
542    if forearm_twist_angle.abs() > 1e-4 {
543        new_lower_world_rot = quat_mul(forearm_twist_world, new_lower_world_rot);
544    }
545
546    // Parent of lower arm is now upper arm with new_upper_world_rot.
547    let full_lower_local = quat_mul(quat_conjugate(new_upper_world_rot), new_lower_world_rot);
548    let lower_local = if weight < 1.0 {
549        let cur = world
550            .get_component_by_id_as::<TransformComponent>(mid_tc)
551            .map(|t| t.transform.rotation)
552            .unwrap_or([0.0, 0.0, 0.0, 1.0]);
553        quat_nlerp(cur, full_lower_local, weight)
554    } else {
555        full_lower_local
556    };
557
558    // Emit UpdateTransform for upper arm (preserve existing translation/scale).
559    let (rt, rs) = world
560        .get_component_by_id_as::<TransformComponent>(root_tc)
561        .map(|t| (t.transform.translation, t.transform.scale))
562        .unwrap_or(([0.0; 3], [1.0, 1.0, 1.0]));
563    emit.push_intent_now(
564        root_tc,
565        IntentValue::UpdateTransform {
566            component_ids: vec![root_tc],
567            translation: rt,
568            rotation_quat_xyzw: upper_local,
569            scale: rs,
570        },
571    );
572
573    // Emit UpdateTransform for lower arm.
574    let (mt, ms) = world
575        .get_component_by_id_as::<TransformComponent>(mid_tc)
576        .map(|t| (t.transform.translation, t.transform.scale))
577        .unwrap_or(([0.0; 3], [1.0, 1.0, 1.0]));
578    emit.push_intent_now(
579        mid_tc,
580        IntentValue::UpdateTransform {
581            component_ids: vec![mid_tc],
582            translation: mt,
583            rotation_quat_xyzw: lower_local,
584            scale: ms,
585        },
586    );
587
588    // Optionally copy target rotation to end-effector bone.
589    if copy_end_rotation {
590        let full_end_local = quat_mul(quat_conjugate(new_lower_world_rot), target_world_rot);
591        let end_local = if weight < 1.0 {
592            let cur = world
593                .get_component_by_id_as::<TransformComponent>(end_tc)
594                .map(|t| t.transform.rotation)
595                .unwrap_or([0.0, 0.0, 0.0, 1.0]);
596            quat_nlerp(cur, full_end_local, weight)
597        } else {
598            full_end_local
599        };
600        let (et, es) = world
601            .get_component_by_id_as::<TransformComponent>(end_tc)
602            .map(|t| (t.transform.translation, t.transform.scale))
603            .unwrap_or(([0.0; 3], [1.0, 1.0, 1.0]));
604        emit.push_intent_now(
605            end_tc,
606            IntentValue::UpdateTransform {
607                component_ids: vec![end_tc],
608                translation: et,
609                rotation_quat_xyzw: end_local,
610                scale: es,
611            },
612        );
613    }
614}
615
616fn extract_twist_about_axis(delta_rot: [f32; 4], axis_world: [f32; 3]) -> [f32; 4] {
617    let axis = vec3_normalize(axis_world);
618    if vec3_len(axis) <= 1e-6 {
619        return [0.0, 0.0, 0.0, 1.0];
620    }
621    let projected = vec3_scale(
622        axis,
623        vec3_dot([delta_rot[0], delta_rot[1], delta_rot[2]], axis),
624    );
625    let twist = [projected[0], projected[1], projected[2], delta_rot[3]];
626    let len_sq =
627        twist[0] * twist[0] + twist[1] * twist[1] + twist[2] * twist[2] + twist[3] * twist[3];
628    if len_sq <= 1e-12 {
629        [0.0, 0.0, 0.0, 1.0]
630    } else {
631        let inv_len = len_sq.sqrt().recip();
632        [
633            twist[0] * inv_len,
634            twist[1] * inv_len,
635            twist[2] * inv_len,
636            twist[3] * inv_len,
637        ]
638    }
639}
640
641fn maybe_update_two_bone_debug(
642    world: &mut World,
643    emit: &mut dyn SignalEmitter,
644    ik_chain_id: ComponentId,
645    avc_id: Option<ComponentId>,
646    root_pos: [f32; 3],
647    target_pos: [f32; 3],
648    pole_world: [f32; 3],
649    plane_normal: [f32; 3],
650    elbow_pos: [f32; 3],
651) {
652    let enabled = avc_id
653        .and_then(|id| world.get_component_by_id_as::<AvatarControlComponent>(id))
654        .map(|avc| avc.ik_debug)
655        .unwrap_or(false);
656    if !enabled {
657        return;
658    }
659
660    let visuals = ensure_two_bone_debug_visuals(world, ik_chain_id);
661    let pole_tip = vec3_add(root_pos, vec3_scale(vec3_normalize_or_z(pole_world), 0.30));
662    let normal_tip = vec3_add(
663        root_pos,
664        vec3_scale(vec3_normalize_or_z(plane_normal), 0.22),
665    );
666
667    update_debug_segment(
668        world,
669        emit,
670        visuals.target_line,
671        root_pos,
672        target_pos,
673        0.010,
674    );
675    update_debug_segment(world, emit, visuals.pole_line, root_pos, pole_tip, 0.008);
676    update_debug_segment(
677        world,
678        emit,
679        visuals.plane_normal_line,
680        root_pos,
681        normal_tip,
682        0.008,
683    );
684    update_debug_segment(world, emit, visuals.elbow_line, root_pos, elbow_pos, 0.010);
685    update_debug_point(
686        world,
687        emit,
688        visuals.elbow_point,
689        elbow_pos,
690        [0.035, 0.035, 0.035],
691    );
692}
693
694fn ensure_two_bone_debug_visuals(
695    world: &mut World,
696    ik_chain_id: ComponentId,
697) -> TwoBoneIkDebugVisuals {
698    if let Some(existing) = world
699        .get_component_by_id_as::<IKChainComponent>(ik_chain_id)
700        .and_then(|ik| ik.two_bone_debug_visuals)
701    {
702        return existing;
703    }
704
705    let parent = world
706        .get_component_by_id_as::<IKChainComponent>(ik_chain_id)
707        .and_then(|ik| ik.avc_id)
708        .unwrap_or(ik_chain_id);
709
710    let visuals = TwoBoneIkDebugVisuals {
711        target_line: spawn_debug_cube(
712            world,
713            parent,
714            "ik_debug_target_line",
715            (1.0, 0.85, 0.15, 0.95),
716        ),
717        pole_line: spawn_debug_cube(world, parent, "ik_debug_pole_line", (0.10, 0.95, 1.0, 0.95)),
718        plane_normal_line: spawn_debug_cube(
719            world,
720            parent,
721            "ik_debug_plane_normal_line",
722            (1.0, 0.35, 0.80, 0.95),
723        ),
724        elbow_line: spawn_debug_cube(
725            world,
726            parent,
727            "ik_debug_elbow_line",
728            (0.20, 1.0, 0.35, 0.95),
729        ),
730        elbow_point: spawn_debug_cube(world, parent, "ik_debug_elbow_point", (1.0, 1.0, 1.0, 0.95)),
731    };
732
733    if let Some(ik) = world.get_component_by_id_as_mut::<IKChainComponent>(ik_chain_id) {
734        ik.two_bone_debug_visuals = Some(visuals);
735    }
736    visuals
737}
738
739fn spawn_debug_cube(
740    world: &mut World,
741    parent: ComponentId,
742    name: &str,
743    color: (f32, f32, f32, f32),
744) -> ComponentId {
745    let overlay = world.add_component(OverlayComponent::new());
746    let transform =
747        world.add_component_boxed_named(name.to_string(), Box::new(TransformComponent::new()));
748    let renderable = world.add_component(RenderableComponent::cube());
749    let color = world.add_component(ColorComponent::rgba(color.0, color.1, color.2, color.3));
750    let emissive = world.add_component(EmissiveComponent::on());
751    let _ = world.add_child(overlay, transform);
752    let _ = world.add_child(transform, renderable);
753    let _ = world.add_child(renderable, color);
754    let _ = world.add_child(renderable, emissive);
755    let _ = world.add_child(parent, overlay);
756    transform
757}
758
759fn update_debug_segment(
760    world: &World,
761    emit: &mut dyn SignalEmitter,
762    component_id: ComponentId,
763    start: [f32; 3],
764    end: [f32; 3],
765    thickness: f32,
766) {
767    let delta = vec3_sub(end, start);
768    let len = vec3_len(delta);
769    let dir = if len > 1e-6 {
770        vec3_scale(delta, 1.0 / len)
771    } else {
772        [0.0, 0.0, 1.0]
773    };
774    let mid = vec3_scale(vec3_add(start, end), 0.5);
775    let rot = shortest_arc_quat([0.0, 0.0, 1.0], dir);
776    let local_mid = world_point_to_local(world, component_id, mid);
777    let local_rot = world_quat_to_local(world, component_id, rot);
778    emit.push_intent_now(
779        component_id,
780        IntentValue::UpdateTransform {
781            component_ids: vec![component_id],
782            translation: local_mid,
783            rotation_quat_xyzw: local_rot,
784            scale: [thickness, thickness, len.max(thickness)],
785        },
786    );
787}
788
789fn update_debug_point(
790    world: &World,
791    emit: &mut dyn SignalEmitter,
792    component_id: ComponentId,
793    position: [f32; 3],
794    scale: [f32; 3],
795) {
796    let local_pos = world_point_to_local(world, component_id, position);
797    emit.push_intent_now(
798        component_id,
799        IntentValue::UpdateTransform {
800            component_ids: vec![component_id],
801            translation: local_pos,
802            rotation_quat_xyzw: [0.0, 0.0, 0.0, 1.0],
803            scale,
804        },
805    );
806}
807
808fn world_point_to_local(
809    world: &World,
810    component_id: ComponentId,
811    world_point: [f32; 3],
812) -> [f32; 3] {
813    let parent_world = nearest_ancestor_transform_world(world, component_id);
814    let Some(inv_parent) = parent_world.and_then(mat4_inverse) else {
815        return world_point;
816    };
817    let local = mat4_mul_vec4(
818        inv_parent,
819        [world_point[0], world_point[1], world_point[2], 1.0],
820    );
821    if local[3].abs() > 1e-6 {
822        [
823            local[0] / local[3],
824            local[1] / local[3],
825            local[2] / local[3],
826        ]
827    } else {
828        [local[0], local[1], local[2]]
829    }
830}
831
832fn world_quat_to_local(world: &World, component_id: ComponentId, world_rot: [f32; 4]) -> [f32; 4] {
833    let parent_world_rot = nearest_ancestor_transform_world(world, component_id)
834        .map(mat_to_quat)
835        .unwrap_or([0.0, 0.0, 0.0, 1.0]);
836    quat_mul(quat_conjugate(parent_world_rot), world_rot)
837}
838
839fn nearest_ancestor_transform_world(
840    world: &World,
841    component_id: ComponentId,
842) -> Option<[[f32; 4]; 4]> {
843    let mut cur = component_id;
844    while let Some(parent) = world.parent_of(cur) {
845        if let Some(t) = world.get_component_by_id_as::<TransformComponent>(parent) {
846            return Some(t.transform.matrix_world);
847        }
848        cur = parent;
849    }
850    None
851}
852
853fn mat4_mul_vec4(m: [[f32; 4]; 4], v: [f32; 4]) -> [f32; 4] {
854    [
855        m[0][0] * v[0] + m[1][0] * v[1] + m[2][0] * v[2] + m[3][0] * v[3],
856        m[0][1] * v[0] + m[1][1] * v[1] + m[2][1] * v[2] + m[3][1] * v[3],
857        m[0][2] * v[0] + m[1][2] * v[1] + m[2][2] * v[2] + m[3][2] * v[3],
858        m[0][3] * v[0] + m[1][3] * v[1] + m[2][3] * v[2] + m[3][3] * v[3],
859    ]
860}
861
862fn vec3_normalize_or_z(v: [f32; 3]) -> [f32; 3] {
863    let len = vec3_len(v);
864    if len > 1e-6 {
865        vec3_scale(v, 1.0 / len)
866    } else {
867        [0.0, 0.0, 1.0]
868    }
869}
870
871// ---------------------------------------------------------------------------
872// FABRIK solver
873// ---------------------------------------------------------------------------
874
875fn solve_fabrik(
876    world: &World,
877    emit: &mut dyn SignalEmitter,
878    chain: &[ComponentId],
879    target_id: ComponentId,
880    target_position_offset: [f32; 3],
881    max_iterations: u32,
882    tolerance: f32,
883    weight: f32,
884) {
885    let n = chain.len();
886
887    let mut positions: Vec<[f32; 3]> = chain.iter().map(|&tc| tc_world_pos(world, tc)).collect();
888    let bone_lengths: Vec<f32> = (0..n - 1)
889        .map(|i| vec3_len(vec3_sub(positions[i + 1], positions[i])).max(1e-6))
890        .collect();
891    let root_pos = positions[0];
892    // Target = target world pos + R(target_world_rot) * target_position_offset.
893    // Symmetric with solve_aim: lets a Y-only offset like (0, -eye_h, 0) drop the
894    // chase point down along the target's local Y, regardless of target yaw.
895    let target_pos = {
896        let base = tc_world_pos(world, target_id);
897        let target_rot = tc_world_rot(world, target_id);
898        let off = quat_rotate_vec3(target_rot, target_position_offset);
899        [base[0] + off[0], base[1] + off[1], base[2] + off[2]]
900    };
901
902    // FABRIK iterations.
903    for _ in 0..max_iterations {
904        if vec3_len(vec3_sub(*positions.last().unwrap(), target_pos)) < tolerance {
905            break;
906        }
907        // Forward pass — pull end to target.
908        *positions.last_mut().unwrap() = target_pos;
909        for i in (0..n - 1).rev() {
910            let d = vec3_len(vec3_sub(positions[i], positions[i + 1]));
911            let t = if d > 1e-9 { bone_lengths[i] / d } else { 0.0 };
912            positions[i] = vec3_lerp(positions[i + 1], positions[i], t);
913        }
914        // Backward pass — pin root.
915        positions[0] = root_pos;
916        for i in 0..n - 1 {
917            let d = vec3_len(vec3_sub(positions[i + 1], positions[i]));
918            let t = if d > 1e-9 { bone_lengths[i] / d } else { 0.0 };
919            positions[i + 1] = vec3_lerp(positions[i], positions[i + 1], t);
920        }
921    }
922
923    // Convert solved bone directions to local rotations and emit.
924    let mut parent_world_rot = world
925        .parent_of(chain[0])
926        .and_then(|p| world.get_component_by_id_as::<TransformComponent>(p))
927        .map(|t| mat_to_quat(t.transform.matrix_world))
928        .unwrap_or([0.0, 0.0, 0.0, 1.0f32]);
929
930    // Detect neck bone by name — it should be one bone before the head in the chain.
931    // Typical name patterns: "J_Bip_C_Neck", "Armature.Neck", "Neck", etc.
932    let neck_index = chain.iter().position(|&id| {
933        world
934            .get_component_by_id_as::<TransformComponent>(id)
935            .and_then(|_| Some(()))
936            .is_some()
937            && world.children_of(id).iter().any(|&child| {
938                // Head is typically a direct child of neck (or wrapped in splice).
939                let is_head = world
940                    .get_component_by_id_as::<TransformComponent>(child)
941                    .and_then(|_| {
942                        // Just check if it's the next bone in the chain
943                        chain.iter().find(|&&c| c == child).map(|_| ())
944                    })
945                    .is_some();
946                is_head
947            })
948    });
949
950    for i in 0..n - 1 {
951        let tc = chain[i];
952        let cur_world_rot = tc_world_rot(world, tc);
953
954        let cur_fwd = {
955            let from = tc_world_pos(world, tc);
956            let to = tc_world_pos(world, chain[i + 1]);
957            let d = vec3_sub(to, from);
958            if vec3_len(d) > 1e-6 {
959                vec3_normalize(d)
960            } else {
961                [0.0, 0.0, 1.0]
962            }
963        };
964        let desired_fwd = {
965            let d = vec3_sub(positions[i + 1], positions[i]);
966            if vec3_len(d) > 1e-6 {
967                vec3_normalize(d)
968            } else {
969                cur_fwd
970            }
971        };
972
973        // If this is the neck bone, constrain its rotation to be minimal (keep it
974        // rigid relative to the upper torso). The neck should inherit the upper body's
975        // yaw/rotation without bending, so we clamp the rotation angle.
976        let delta = if neck_index == Some(i) {
977            // Neck constraint: allow only small deviations from the current forward direction.
978            // Use a very tight cone (max ~15 degrees) to keep the neck stiff.
979            let unconstrained_delta = shortest_arc_quat(cur_fwd, desired_fwd);
980            let (axis, angle) = quat_to_axis_angle(unconstrained_delta);
981            let max_neck_angle = 0.26f32; // ~15 degrees
982            if angle.abs() > max_neck_angle {
983                // Clamp the rotation angle
984                let clamped_angle = angle.max(-max_neck_angle).min(max_neck_angle);
985                quat_from_axis_angle(axis, clamped_angle)
986            } else {
987                unconstrained_delta
988            }
989        } else {
990            shortest_arc_quat(cur_fwd, desired_fwd)
991        };
992
993        let new_world_rot = quat_mul(delta, cur_world_rot);
994        let full_local = quat_mul(quat_conjugate(parent_world_rot), new_world_rot);
995
996        let local_rot = if weight < 1.0 {
997            let cur = world
998                .get_component_by_id_as::<TransformComponent>(tc)
999                .map(|t| t.transform.rotation)
1000                .unwrap_or([0.0, 0.0, 0.0, 1.0]);
1001            quat_nlerp(cur, full_local, weight)
1002        } else {
1003            full_local
1004        };
1005
1006        let (t, s) = world
1007            .get_component_by_id_as::<TransformComponent>(tc)
1008            .map(|tc| (tc.transform.translation, tc.transform.scale))
1009            .unwrap_or(([0.0; 3], [1.0, 1.0, 1.0]));
1010        emit.push_intent_now(
1011            tc,
1012            IntentValue::UpdateTransform {
1013                component_ids: vec![tc],
1014                translation: t,
1015                rotation_quat_xyzw: local_rot,
1016                scale: s,
1017            },
1018        );
1019
1020        parent_world_rot = new_world_rot;
1021    }
1022}
1023
1024// ---------------------------------------------------------------------------
1025// World-matrix helpers
1026// ---------------------------------------------------------------------------
1027
1028fn tc_world_pos(world: &World, id: ComponentId) -> [f32; 3] {
1029    world
1030        .get_component_by_id_as::<TransformComponent>(id)
1031        .map(|t| {
1032            let m = t.transform.matrix_world;
1033            [m[3][0], m[3][1], m[3][2]]
1034        })
1035        .unwrap_or([0.0; 3])
1036}
1037
1038fn tc_world_rot(world: &World, id: ComponentId) -> [f32; 4] {
1039    world
1040        .get_component_by_id_as::<TransformComponent>(id)
1041        .map(|t| mat_to_quat(t.transform.matrix_world))
1042        .unwrap_or([0.0, 0.0, 0.0, 1.0])
1043}
1044
1045fn read_bone_rest_rot(world: &World, bone_id: ComponentId) -> [f32; 4] {
1046    world
1047        .children_of(bone_id)
1048        .iter()
1049        .find_map(|&c| world.get_component_by_id_as::<BoneRestPoseComponent>(c))
1050        .map(|rest| rest.rotation)
1051        .or_else(|| {
1052            world
1053                .get_component_by_id_as::<TransformComponent>(bone_id)
1054                .map(|t| t.transform.rotation)
1055        })
1056        .unwrap_or([0.0, 0.0, 0.0, 1.0])
1057}
1058
1059#[cfg(test)]
1060mod tests {
1061    use super::*;
1062    use crate::engine::ecs::CommandQueue;
1063    use crate::engine::ecs::IntentValue;
1064    use crate::engine::ecs::component::{
1065        BoneRestPoseComponent, ComponentRef, IKChainComponent, IKSolver, TransformComponent,
1066    };
1067    use slotmap::Key;
1068
1069    #[derive(Default)]
1070    struct TestEmitter {
1071        intents: Vec<(ComponentId, IntentValue)>,
1072    }
1073
1074    impl SignalEmitter for TestEmitter {
1075        fn push_event(&mut self, _scope: ComponentId, _event: crate::engine::ecs::EventSignal) {}
1076
1077        fn push_intent(&mut self, scope: ComponentId, intent: crate::engine::ecs::IntentSignal) {
1078            self.intents.push((scope, intent.value));
1079        }
1080    }
1081
1082    #[test]
1083    fn xr_driven_chain_skips_until_pose_is_valid() {
1084        let mut world = World::default();
1085        let driver = world.add_component(crate::engine::ecs::component::InputXRComponent::on());
1086        let target = world.add_component(TransformComponent::new());
1087        let root = world.add_component(TransformComponent::new());
1088        world.add_child(driver, target).unwrap();
1089
1090        let mut chain = IKChainComponent::new(
1091            IKSolver::AimConstraint {
1092                offset_yaw: 0.0,
1093                copy_position: false,
1094                target_position_offset: [0.0; 3],
1095            },
1096            target,
1097            root,
1098        );
1099        chain.xr_pose_driver = Some(driver);
1100        let chain_id = world.add_component(chain);
1101        world.add_child(root, chain_id).unwrap();
1102
1103        let mut emit = TestEmitter::default();
1104        tick_chain(chain_id, &mut world, &mut emit);
1105        assert!(emit.intents.is_empty());
1106
1107        world
1108            .get_component_by_id_as_mut::<crate::engine::ecs::component::InputXRComponent>(driver)
1109            .unwrap()
1110            .pose_valid = true;
1111        tick_chain(chain_id, &mut world, &mut emit);
1112        assert!(!emit.intents.is_empty());
1113    }
1114
1115    // Temporarily gated: see docs/bugs/ik-solver-api-drift-breaks-tests.md.
1116    #[cfg(any())]
1117    #[test]
1118    fn resolves_forward_reference_on_first_tick() {
1119        let mut w = World::default();
1120
1121        // Author IKChain *before* the target/end_effector components exist.
1122        // Both ids start as null sentinels; only the sources carry the
1123        // selector strings.
1124        let ik_id = w.add_component(
1125            IKChainComponent::new(
1126                IKSolver::TwoBoneIK {
1127                    root_joint_id: ComponentId::null(),
1128                    mid_joint_id: ComponentId::null(),
1129                    pole_direction: [0.0, 1.0, 0.0],
1130                    copy_end_rotation: false,
1131                },
1132                ComponentId::null(),
1133                ComponentId::null(),
1134            )
1135            .with_target_source(ComponentRef::Query("#hand".to_string()))
1136            .with_end_effector_source(ComponentRef::Query("#elbow".to_string())),
1137        );
1138
1139        // Now spawn the targets the IKChain refers to.
1140        let hand = w.add_component_boxed_named("hand", Box::new(TransformComponent::new()));
1141        let elbow = w.add_component_boxed_named("elbow", Box::new(TransformComponent::new()));
1142
1143        // Sanity: nothing resolved yet.
1144        {
1145            let ik = w.get_component_by_id_as::<IKChainComponent>(ik_id).unwrap();
1146            assert!(ik.target_id.is_null());
1147            assert!(ik.end_effector_id.is_null());
1148        }
1149
1150        // First tick triggers the deferred resolve.
1151        let mut emit = CommandQueue::new();
1152        let mut sys = IKSystem::new();
1153        sys.tick(&mut w, &mut emit, 0.016);
1154
1155        let ik = w.get_component_by_id_as::<IKChainComponent>(ik_id).unwrap();
1156        assert_eq!(ik.target_id, hand);
1157        assert_eq!(ik.end_effector_id, elbow);
1158    }
1159
1160    #[test]
1161    fn does_not_overwrite_already_resolved_ids() {
1162        let mut w = World::default();
1163        let pre_target = w.add_component(TransformComponent::new());
1164        let pre_ee = w.add_component(TransformComponent::new());
1165        let unrelated = w.add_component_boxed_named("hand", Box::new(TransformComponent::new()));
1166
1167        let ik_id = w.add_component(
1168            IKChainComponent::new(
1169                IKSolver::AimConstraint {
1170                    offset_yaw: 0.0,
1171                    copy_position: false,
1172                    target_position_offset: [0.0, 0.0, 0.0],
1173                },
1174                pre_target,
1175                pre_ee,
1176            )
1177            // Source points at a different component named "hand" — but
1178            // since target_id / end_effector_id are already non-null, the
1179            // resolve pass should leave them alone.
1180            .with_target_source(ComponentRef::Query("#hand".to_string())),
1181        );
1182
1183        let mut emit = CommandQueue::new();
1184        let mut sys = IKSystem::new();
1185        sys.tick(&mut w, &mut emit, 0.016);
1186
1187        let ik = w.get_component_by_id_as::<IKChainComponent>(ik_id).unwrap();
1188        assert_eq!(ik.target_id, pre_target);
1189        assert_ne!(ik.target_id, unrelated);
1190        assert_eq!(ik.end_effector_id, pre_ee);
1191    }
1192
1193    #[test]
1194    fn resolves_relative_parent_prefixed_sources() {
1195        let mut w = World::default();
1196        let root = w.add_component(TransformComponent::new());
1197        let hand = w.add_component_boxed_named("hand", Box::new(TransformComponent::new()));
1198        let elbow = w.add_component_boxed_named("elbow", Box::new(TransformComponent::new()));
1199        w.add_child(root, hand).unwrap();
1200        w.add_child(root, elbow).unwrap();
1201
1202        let ik_id = w.add_component(
1203            IKChainComponent::new(
1204                IKSolver::TwoBoneIK {
1205                    root_joint_id: ComponentId::null(),
1206                    mid_joint_id: ComponentId::null(),
1207                    pole_direction: [0.0, 1.0, 0.0],
1208                    copy_end_rotation: false,
1209                },
1210                ComponentId::null(),
1211                ComponentId::null(),
1212            )
1213            .with_target_source(ComponentRef::Query("../#hand".to_string()))
1214            .with_end_effector_source(ComponentRef::Query("../#elbow".to_string())),
1215        );
1216        w.add_child(root, ik_id).unwrap();
1217
1218        let mut emit = CommandQueue::new();
1219        let mut sys = IKSystem::new();
1220        sys.tick(&mut w, &mut emit, 0.016);
1221
1222        let ik = w.get_component_by_id_as::<IKChainComponent>(ik_id).unwrap();
1223        assert_eq!(ik.target_id, hand);
1224        assert_eq!(ik.end_effector_id, elbow);
1225    }
1226
1227    #[test]
1228    fn resolves_bare_sources_from_local_scope() {
1229        let mut w = World::default();
1230
1231        let unrelated_root = w.add_component(TransformComponent::new());
1232        let unrelated_hand =
1233            w.add_component_boxed_named("hand", Box::new(TransformComponent::new()));
1234        w.add_child(unrelated_root, unrelated_hand).unwrap();
1235
1236        let root = w.add_component(TransformComponent::new());
1237
1238        let ik_id = w.add_component(
1239            IKChainComponent::new(
1240                IKSolver::TwoBoneIK {
1241                    root_joint_id: ComponentId::null(),
1242                    mid_joint_id: ComponentId::null(),
1243                    pole_direction: [0.0, 1.0, 0.0],
1244                    copy_end_rotation: false,
1245                },
1246                ComponentId::null(),
1247                ComponentId::null(),
1248            )
1249            .with_target_source(ComponentRef::Query("#hand".to_string()))
1250            .with_end_effector_source(ComponentRef::Query("#elbow".to_string())),
1251        );
1252        w.add_child(root, ik_id).unwrap();
1253        let hand = w.add_component_boxed_named("hand", Box::new(TransformComponent::new()));
1254        let elbow = w.add_component_boxed_named("elbow", Box::new(TransformComponent::new()));
1255        w.add_child(ik_id, hand).unwrap();
1256        w.add_child(ik_id, elbow).unwrap();
1257
1258        let mut emit = CommandQueue::new();
1259        let mut sys = IKSystem::new();
1260        sys.tick(&mut w, &mut emit, 0.016);
1261
1262        let ik = w.get_component_by_id_as::<IKChainComponent>(ik_id).unwrap();
1263        assert_eq!(ik.target_id, hand);
1264        assert_ne!(ik.target_id, unrelated_hand);
1265        assert_eq!(ik.end_effector_id, elbow);
1266    }
1267
1268    #[test]
1269    fn two_bone_forearm_twist_uses_hand_rest_rotation_not_live_hand_local_rotation() {
1270        let mut w = World::default();
1271        let root = w.add_component(TransformComponent::new().with_position(0.0, 0.0, 0.0));
1272        let mid = w.add_component(TransformComponent::new().with_position(1.0, 0.0, 0.0));
1273        let hand = w.add_component(
1274            TransformComponent::new()
1275                .with_position(2.0, 0.0, 0.0)
1276                .with_rotation_quat(quat_from_axis_angle(
1277                    [1.0, 0.0, 0.0],
1278                    std::f32::consts::FRAC_PI_2,
1279                )),
1280        );
1281        let target = w.add_component(TransformComponent::new().with_position(2.0, 0.0, 0.0));
1282        let hand_rest = w.add_component(BoneRestPoseComponent::new(
1283            [0.0, 0.0, 0.0],
1284            [0.0, 0.0, 0.0, 1.0],
1285            [1.0, 1.0, 1.0],
1286        ));
1287        w.add_child(hand, hand_rest).unwrap();
1288
1289        let mut emit = TestEmitter::default();
1290        solve_two_bone(
1291            &mut w,
1292            &mut emit,
1293            ComponentId::null(),
1294            &[root, mid, hand],
1295            target,
1296            [0.0, 1.0, 0.0],
1297            true,
1298            1.0,
1299            None,
1300        );
1301
1302        let lower_update = emit
1303            .intents
1304            .iter()
1305            .find_map(|(scope, value)| match (scope, value) {
1306                (
1307                    scope_id,
1308                    IntentValue::UpdateTransform {
1309                        rotation_quat_xyzw, ..
1310                    },
1311                ) if *scope_id == mid => Some(*rotation_quat_xyzw),
1312                _ => None,
1313            })
1314            .expect("lower arm update intent");
1315
1316        let (_, lower_angle) = quat_to_axis_angle(lower_update);
1317        assert!(
1318            lower_angle.abs() < 1e-4,
1319            "expected no forearm twist from live hand local rotation, got {lower_angle}"
1320        );
1321    }
1322}