Skip to main content

roxlap_core/
kfa_draw.rs

1//! KFA (animated kv6) bone solver — the bone-transform helpers
2//! `genperp`, `mat0`, and `setlimb`.
3//!
4//! A KFA sprite is a hierarchy of bones; each bone carries an
5//! optional [`Sprite`] (= one kv6 limb) plus a hinge tying it to
6//! its parent. Per frame, the user updates `kfaval[i]` (a 16-bit
7//! angle) for each animated bone; [`solve_kfa_limbs`] walks the hinge
8//! tree in topological order and computes each limb's world transform
9//! from its parent's. The caller then draws each posed limb via
10//! [`crate::dda_sprite::draw_sprite_dda`].
11//!
12//! Animation-curve playback (advancing `kfaval[]` from a baked
13//! keyframe sequence) lives in
14//! [`roxlap_formats::kfa::KfaSprite::animsprite`] — call it to
15//! advance `kfaval[]` from a baked curve, or poke `kfaval[]`
16//! directly for procedural animation, then render here. The bone
17//! posing this module computes is also exposed standalone as
18//! [`solve_kfa_limbs`] for non-CPU backends (the GPU sprite pass).
19//!
20//! No oracle pose exercises KFA, so this module's correctness
21//! gate is "looks right + tests verify the bone math". We can
22//! tighten validation when a real `.kfa` asset lands.
23
24#![allow(
25    clippy::cast_possible_truncation,
26    clippy::cast_possible_wrap,
27    clippy::cast_sign_loss,
28    clippy::cast_precision_loss,
29    clippy::similar_names,
30    clippy::too_many_arguments,
31    clippy::doc_markdown,
32    clippy::many_single_char_names,
33    clippy::missing_panics_doc,
34    clippy::float_cmp,
35    clippy::useless_vec
36)]
37
38use roxlap_formats::kfa::{Hinge, KfaSprite, Point3};
39use roxlap_formats::sprite::Sprite;
40use roxlap_formats::xform::BoneXform;
41
42/// 3×3 + translation matrix multiply. Composes transform
43/// `(a_s, a_h, a_f, a_o)` with `(b_s, b_h, b_f, b_o)` into
44/// `(c_s, c_h, c_f, c_o)`: `c_* = a_s·b_*.x + a_h·b_*.y + a_f·b_*.z`,
45/// and `c_o` adds `a_o`. Used by [`setlimb`] to chain a child bone's
46/// world transform onto its parent's.
47#[allow(clippy::too_many_arguments)]
48fn mat2(
49    a_s: [f32; 3],
50    a_h: [f32; 3],
51    a_f: [f32; 3],
52    a_o: [f32; 3],
53    b_s: [f32; 3],
54    b_h: [f32; 3],
55    b_f: [f32; 3],
56    b_o: [f32; 3],
57) -> ([f32; 3], [f32; 3], [f32; 3], [f32; 3]) {
58    let c_s = [
59        a_s[0] * b_s[0] + a_h[0] * b_s[1] + a_f[0] * b_s[2],
60        a_s[1] * b_s[0] + a_h[1] * b_s[1] + a_f[1] * b_s[2],
61        a_s[2] * b_s[0] + a_h[2] * b_s[1] + a_f[2] * b_s[2],
62    ];
63    let c_h = [
64        a_s[0] * b_h[0] + a_h[0] * b_h[1] + a_f[0] * b_h[2],
65        a_s[1] * b_h[0] + a_h[1] * b_h[1] + a_f[1] * b_h[2],
66        a_s[2] * b_h[0] + a_h[2] * b_h[1] + a_f[2] * b_h[2],
67    ];
68    let c_f = [
69        a_s[0] * b_f[0] + a_h[0] * b_f[1] + a_f[0] * b_f[2],
70        a_s[1] * b_f[0] + a_h[1] * b_f[1] + a_f[1] * b_f[2],
71        a_s[2] * b_f[0] + a_h[2] * b_f[1] + a_f[2] * b_f[2],
72    ];
73    let c_o = [
74        a_s[0] * b_o[0] + a_h[0] * b_o[1] + a_f[0] * b_o[2] + a_o[0],
75        a_s[1] * b_o[0] + a_h[1] * b_o[1] + a_f[1] * b_o[2] + a_o[1],
76        a_s[2] * b_o[0] + a_h[2] * b_o[1] + a_f[2] * b_o[2] + a_o[2],
77    ];
78    (c_s, c_h, c_f, c_o)
79}
80
81/// Given a non-zero axis vector `a`, build two unit vectors `b`, `c`
82/// such that `(a, b, c)` is a right-handed orthogonal frame with `a` as
83/// the primary axis (`a` is not normalised; `b` and `c` are). A zero
84/// input returns `([0; 3], [0; 3])`.
85///
86/// `b` is formed by zeroing `a`'s smallest-magnitude component and
87/// normalising the perpendicular it leaves; `c = a × b` completes the
88/// frame. Zeroing the smallest component keeps the perpendicular
89/// well-conditioned.
90fn genperp(a: [f32; 3]) -> ([f32; 3], [f32; 3]) {
91    if a == [0.0, 0.0, 0.0] {
92        return ([0.0; 3], [0.0; 3]);
93    }
94    // Pick the smallest-magnitude axis to zero out in `b`, so the
95    // remaining two components dominate and the cross-product
96    // `c = a × b` stays well-conditioned.
97    let ax = a[0].abs();
98    let ay = a[1].abs();
99    let az = a[2].abs();
100    let b = if ax < ay && ax < az {
101        let t = 1.0 / (a[1] * a[1] + a[2] * a[2]).sqrt();
102        [0.0, a[2] * t, -a[1] * t]
103    } else if ay < az {
104        let t = 1.0 / (a[0] * a[0] + a[2] * a[2]).sqrt();
105        [-a[2] * t, 0.0, a[0] * t]
106    } else {
107        let t = 1.0 / (a[0] * a[0] + a[1] * a[1]).sqrt();
108        [a[1] * t, -a[0] * t, 0.0]
109    };
110    let c = [
111        a[1] * b[2] - a[2] * b[1],
112        a[2] * b[0] - a[0] * b[2],
113        a[0] * b[1] - a[1] * b[0],
114    ];
115    (b, c)
116}
117
118/// Given orthonormal frames `B` and `C` (each a 3×3 basis + origin),
119/// find the rigid transform `A` with `A · B = C`. Since `B` is
120/// orthonormal, `A = C · Bᵀ`: each column of `A` is `C`'s column
121/// re-expressed in `B`'s frame, and the origin follows by subtracting
122/// `A · B_o`. Used by [`limb_xform`] to find the rotation mapping a
123/// bone's parent-side hinge frame onto its child-side frame.
124fn mat0(
125    b_s: [f32; 3],
126    b_h: [f32; 3],
127    b_f: [f32; 3],
128    b_o: [f32; 3],
129    c_s: [f32; 3],
130    c_h: [f32; 3],
131    c_f: [f32; 3],
132    c_o: [f32; 3],
133) -> ([f32; 3], [f32; 3], [f32; 3], [f32; 3]) {
134    // Each output column = C's column re-expressed in B's frame
135    // (= C · Bᵀ, B orthonormal).
136    let ts = [
137        b_s[0] * c_s[0] + b_h[0] * c_h[0] + b_f[0] * c_f[0],
138        b_s[0] * c_s[1] + b_h[0] * c_h[1] + b_f[0] * c_f[1],
139        b_s[0] * c_s[2] + b_h[0] * c_h[2] + b_f[0] * c_f[2],
140    ];
141    let th = [
142        b_s[1] * c_s[0] + b_h[1] * c_h[0] + b_f[1] * c_f[0],
143        b_s[1] * c_s[1] + b_h[1] * c_h[1] + b_f[1] * c_f[1],
144        b_s[1] * c_s[2] + b_h[1] * c_h[2] + b_f[1] * c_f[2],
145    ];
146    let tf = [
147        b_s[2] * c_s[0] + b_h[2] * c_h[0] + b_f[2] * c_f[0],
148        b_s[2] * c_s[1] + b_h[2] * c_h[1] + b_f[2] * c_f[1],
149        b_s[2] * c_s[2] + b_h[2] * c_h[2] + b_f[2] * c_f[2],
150    ];
151    let to = [
152        c_o[0] - b_o[0] * ts[0] - b_o[1] * th[0] - b_o[2] * tf[0],
153        c_o[1] - b_o[0] * ts[1] - b_o[1] * th[1] - b_o[2] * tf[1],
154        c_o[2] - b_o[0] * ts[2] - b_o[1] * th[2] - b_o[2] * tf[2],
155    ];
156    (ts, th, tf, to)
157}
158
159#[inline]
160fn pt(p: Point3) -> [f32; 3] {
161    [p.x, p.y, p.z]
162}
163
164/// Compute child limb `i`'s world transform from its parent's, via the
165/// hinge connecting them, and write it into `limbs[i]`. Thin wrapper
166/// over [`limb_xform`] (the pure math).
167fn setlimb(limbs: &mut [Sprite], hinges: &[Hinge], i: usize, parent: usize, xform: &BoneXform) {
168    let p = &limbs[parent];
169    let (cs, ch, cf, co) = limb_xform((p.s, p.h, p.f, p.p), &hinges[i], xform);
170    let child = &mut limbs[i];
171    child.s = cs;
172    child.h = ch;
173    child.f = cf;
174    child.p = co;
175}
176
177/// The pure `setlimb` math: a child bone's world `(s, h, f, p)` from its
178/// parent's world transform, the connecting `hinge`, and the bone's local
179/// [`BoneXform`]. Split out from [`setlimb`] (which writes into the
180/// `Sprite` list) so it can be reasoned about / tested in isolation.
181fn limb_xform(
182    parent: ([f32; 3], [f32; 3], [f32; 3], [f32; 3]),
183    hinge: &Hinge,
184    xform: &BoneXform,
185) -> ([f32; 3], [f32; 3], [f32; 3], [f32; 3]) {
186    let (parent_s, parent_h, parent_f, parent_p) = parent;
187
188    // Step 1: child-side anchor frame, with the animated translation added to
189    // the child anchor (anchor-local).
190    let qp0 = pt(hinge.p[0]);
191    let qp = [
192        qp0[0] + xform.t[0],
193        qp0[1] + xform.t[1],
194        qp0[2] + xform.t[2],
195    ];
196    let qs0 = pt(hinge.v[0]);
197    let (qh0, qf0) = genperp(qs0);
198
199    // Step 2: apply the hinge rotation by rotating the whole anchor frame
200    // by the quaternion. A rotation about the hinge axis leaves `qs` fixed
201    // and spins `(qh, qf)` in their plane — the legacy single-axis hinge
202    // case (see `BoneXform::from_hinge_angle`); a free quaternion gives
203    // full 3-DOF.
204    let qs = xform.r.rotate(qs0);
205    let qh = xform.r.rotate(qh0);
206    let qf = xform.r.rotate(qf0);
207
208    // Step 3: parent-side anchor frame.
209    let pp = pt(hinge.p[1]);
210    let ps = pt(hinge.v[1]);
211    let (ph, pf) = genperp(ps);
212
213    // Step 4: mat0 — find R such that R * (ps,ph,pf,pp) = (qs,qh,qf,qp).
214    let (rs, rh, rf, ro) = mat0(ps, ph, pf, pp, qs, qh, qf, qp);
215
216    // Step 5: mat2 — child_world = parent_world * R.
217    let (cs, ch, cf, co) = mat2(parent_s, parent_h, parent_f, parent_p, rs, rh, rf, ro);
218
219    // Step 6: non-uniform scale along the bone's local axes — the Sprite basis
220    // vectors' length scales the kv6 (children inherit it via `mat2`).
221    (
222        [cs[0] * xform.s[0], cs[1] * xform.s[0], cs[2] * xform.s[0]],
223        [ch[0] * xform.s[1], ch[1] * xform.s[1], ch[2] * xform.s[1]],
224        [cf[0] * xform.s[2], cf[1] * xform.s[2], cf[2] * xform.s[2]],
225        co,
226    )
227}
228
229/// Pose every limb of a KFA sprite. Walks the hinge tree in
230/// topological order (parents first) and writes each limb's world
231/// `(s, h, f, p)` from its parent's via the per-limb `setlimb` math,
232/// reading the current [`KfaSprite::kfaval`] angles. Mirror of the
233/// bone-hierarchy transform solve.
234///
235/// Split out so non-CPU backends (e.g. the GPU instanced-sprite pass)
236/// can run the exact same posing and then consume `kfa.limbs[*]`
237/// transforms however they need (e.g. the renderer draws each limb via
238/// [`crate::dda_sprite::draw_sprite_dda`]). The host typically calls
239/// [`KfaSprite::animsprite`](roxlap_formats::kfa::KfaSprite::animsprite)
240/// to advance `kfaval[]` first, then this to resolve world transforms.
241pub fn solve_kfa_limbs(kfa: &mut KfaSprite) {
242    // `hinge_sort` orders bones parents-before-children when walked in
243    // reverse (parents sit at the high indices), so descending iteration
244    // resolves each parent before its children.
245    let n = kfa.hinge_sort.len();
246    for k in (0..n).rev() {
247        let j = kfa.hinge_sort[k];
248        let parent = kfa.hinges[j].parent;
249        if parent >= 0 {
250            // Child bone: derive transform from parent using the bone's
251            // resolved local TRS (`kfaval`). A non-zero `htype` means "no
252            // rotation/transform".
253            let htype = kfa.hinges[j].htype;
254            let xform = if htype == 0 {
255                kfa.kfaval[j]
256            } else {
257                BoneXform::IDENTITY
258            };
259            setlimb(&mut kfa.limbs, &kfa.hinges, j, parent as usize, &xform);
260        } else {
261            // Root bone: copy world basis from KfaSprite + apply
262            // hinge.p[0] as the anchor offset.
263            let s = kfa.s;
264            let h = kfa.h;
265            let f = kfa.f;
266            let p_world = kfa.p;
267            let tp = pt(kfa.hinges[j].p[0]);
268            let limb = &mut kfa.limbs[j];
269            limb.s = s;
270            limb.h = h;
271            limb.f = f;
272            limb.p = [
273                p_world[0] - tp[0] * s[0] - tp[1] * h[0] - tp[2] * f[0],
274                p_world[1] - tp[0] * s[1] - tp[1] * h[1] - tp[2] * f[1],
275                p_world[2] - tp[0] * s[2] - tp[1] * h[2] - tp[2] * f[2],
276            ];
277        }
278    }
279}
280
281/// Compose a bone's solved world transform with an attachment's
282/// `local_offset` (VCL.6), giving the attachment's world pose `(s, h, f,
283/// p)` — the basis columns + position to hand a sprite/clip instance.
284///
285/// `(bs, bh, bf, bp)` are the bone's world basis columns + position (e.g.
286/// `solve_kfa_limbs` output `limbs[bone].{s, h, f, p}`); `off` is the
287/// attachment's local TRS in the bone's frame. The offset's rotation +
288/// scale build the attachment's local basis, then the bone basis maps it
289/// (and the offset translation) into world space. An identity `off`
290/// returns the bone transform unchanged.
291#[must_use]
292pub fn compose_attachment(
293    bs: [f32; 3],
294    bh: [f32; 3],
295    bf: [f32; 3],
296    bp: [f32; 3],
297    off: &BoneXform,
298) -> ([f32; 3], [f32; 3], [f32; 3], [f32; 3]) {
299    // Map a bone-local vector into world space via the bone basis columns.
300    let mbone = |v: [f32; 3]| {
301        [
302            bs[0] * v[0] + bh[0] * v[1] + bf[0] * v[2],
303            bs[1] * v[0] + bh[1] * v[1] + bf[1] * v[2],
304            bs[2] * v[0] + bh[2] * v[1] + bf[2] * v[2],
305        ]
306    };
307    // Attachment local basis = rotation · scale, in the bone frame.
308    let lx = off.r.rotate([off.s[0], 0.0, 0.0]);
309    let ly = off.r.rotate([0.0, off.s[1], 0.0]);
310    let lz = off.r.rotate([0.0, 0.0, off.s[2]]);
311    let s = mbone(lx);
312    let h = mbone(ly);
313    let f = mbone(lz);
314    let tp = mbone(off.t);
315    let p = [bp[0] + tp[0], bp[1] + tp[1], bp[2] + tp[2]];
316    (s, h, f, p)
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use roxlap_formats::xform::Quat;
323
324    #[test]
325    fn compose_attachment_identity_and_offset() {
326        let (bs, bh, bf, bp) = (
327            [1.0, 0.0, 0.0],
328            [0.0, 1.0, 0.0],
329            [0.0, 0.0, 1.0],
330            [10.0, 20.0, 30.0],
331        );
332
333        // Identity offset → the attachment IS the bone transform.
334        let r = compose_attachment(bs, bh, bf, bp, &BoneXform::IDENTITY);
335        assert_eq!(r, (bs, bh, bf, bp));
336
337        // Pure translation, identity bone basis → p + t.
338        let off = BoneXform {
339            t: [1.0, 2.0, 3.0],
340            r: Quat::IDENTITY,
341            s: [1.0, 1.0, 1.0],
342        };
343        let (_, _, _, p) = compose_attachment(bs, bh, bf, bp, &off);
344        assert_eq!(p, [11.0, 22.0, 33.0]);
345    }
346
347    #[test]
348    fn compose_attachment_offset_is_in_bone_space() {
349        // Bone basis rotated 90° about world z: local +x ↦ world +y.
350        let (bs, bh, bf, bp) = (
351            [0.0, 1.0, 0.0],
352            [-1.0, 0.0, 0.0],
353            [0.0, 0.0, 1.0],
354            [0.0, 0.0, 0.0],
355        );
356        let off = BoneXform {
357            t: [2.0, 0.0, 0.0], // 2 along the bone's local +x
358            r: Quat::IDENTITY,
359            s: [1.0, 1.0, 1.0],
360        };
361        let (s, _, _, p) = compose_attachment(bs, bh, bf, bp, &off);
362        // The offset translation lands along world +y (the bone's local +x).
363        assert!((p[0]).abs() < 1e-6 && (p[1] - 2.0).abs() < 1e-6 && p[2].abs() < 1e-6);
364        // And the attachment basis inherits the bone basis (identity offset rot).
365        assert!(
366            (s[1] - 1.0).abs() < 1e-6,
367            "local +x stays the bone's +x (world +y)"
368        );
369    }
370
371    /// genperp produces an orthonormal basis with the input axis
372    /// as the dominant direction.
373    #[test]
374    fn genperp_orthonormal() {
375        let a = [1.0_f32, 0.0, 0.0];
376        let (b, c) = genperp(a);
377        // b · a = 0
378        assert!((a[0] * b[0] + a[1] * b[1] + a[2] * b[2]).abs() < 1e-6);
379        // c · a = 0
380        assert!((a[0] * c[0] + a[1] * c[1] + a[2] * c[2]).abs() < 1e-6);
381        // b · c = 0
382        assert!((b[0] * c[0] + b[1] * c[1] + b[2] * c[2]).abs() < 1e-6);
383        // |b| ≈ 1
384        let lb = b[0] * b[0] + b[1] * b[1] + b[2] * b[2];
385        assert!((lb - 1.0).abs() < 1e-5, "|b|² = {lb}");
386        // |c| ≈ 1
387        let lc = c[0] * c[0] + c[1] * c[1] + c[2] * c[2];
388        assert!((lc - 1.0).abs() < 1e-5, "|c|² = {lc}");
389    }
390
391    /// genperp of zero vector returns zero vectors.
392    #[test]
393    fn genperp_zero() {
394        let (b, c) = genperp([0.0, 0.0, 0.0]);
395        assert_eq!(b, [0.0, 0.0, 0.0]);
396        assert_eq!(c, [0.0, 0.0, 0.0]);
397    }
398
399    /// The single-axis hinge rotation in closed form (rotate `(qh, qf)`
400    /// about `qs` by the Q15 angle, no translation / scale), kept as the
401    /// reference the general `BoneXform` path must reproduce.
402    fn legacy_limb_xform(
403        parent: ([f32; 3], [f32; 3], [f32; 3], [f32; 3]),
404        hinge: &Hinge,
405        val: i16,
406    ) -> ([f32; 3], [f32; 3], [f32; 3], [f32; 3]) {
407        let (ps0, ph0, pf0, pp0) = parent;
408        let qp = pt(hinge.p[0]);
409        let qs = pt(hinge.v[0]);
410        let (mut qh, mut qf) = genperp(qs);
411        let ang = (i32::from(val) as f32) * (std::f32::consts::PI * 2.0 / 65536.0);
412        let (c, s) = (ang.cos(), ang.sin());
413        let (ih, jf) = (qh, qf);
414        qh = [
415            ih[0] * c - jf[0] * s,
416            ih[1] * c - jf[1] * s,
417            ih[2] * c - jf[2] * s,
418        ];
419        qf = [
420            ih[0] * s + jf[0] * c,
421            ih[1] * s + jf[1] * c,
422            ih[2] * s + jf[2] * c,
423        ];
424        let pp = pt(hinge.p[1]);
425        let ps = pt(hinge.v[1]);
426        let (ph, pf) = genperp(ps);
427        let (rs, rh, rf, ro) = mat0(ps, ph, pf, pp, qs, qh, qf, qp);
428        mat2(ps0, ph0, pf0, pp0, rs, rh, rf, ro)
429    }
430
431    /// The new TRS solver, fed a rotation-only `BoneXform` from a hinge angle,
432    /// reproduces the legacy single-axis `setlimb` to f32 epsilon — so the
433    /// quaternion rewrite is behaviour-preserving for existing rigs.
434    #[test]
435    fn trs_solver_matches_the_legacy_hinge_rotation() {
436        let axis = Point3 {
437            x: 0.0,
438            y: 0.0,
439            z: 1.0,
440        };
441        let hinge = Hinge {
442            parent: 0,
443            p: [
444                Point3 {
445                    x: 0.0,
446                    y: 0.0,
447                    z: 0.0,
448                },
449                Point3 {
450                    x: 6.0,
451                    y: 0.0,
452                    z: 0.0,
453                },
454            ],
455            v: [axis, axis],
456            vmin: i16::MIN,
457            vmax: i16::MAX,
458            htype: 0,
459            filler: [0; 7],
460        };
461        // An identity (axis-aligned, origin) parent world transform.
462        let parent = (
463            [1.0, 0.0, 0.0],
464            [0.0, 1.0, 0.0],
465            [0.0, 0.0, 1.0],
466            [0.0, 0.0, 0.0],
467        );
468        let close = |a: [f32; 3], b: [f32; 3]| (0..3).all(|i| (a[i] - b[i]).abs() < 1e-4);
469        for val in [0i16, 8000, 16384, -16384, 30000, i16::MIN] {
470            let want = legacy_limb_xform(parent, &hinge, val);
471            let got = limb_xform(parent, &hinge, &BoneXform::from_hinge_angle(pt(axis), val));
472            assert!(
473                close(got.0, want.0),
474                "s mismatch at {val}: {:?} vs {:?}",
475                got.0,
476                want.0
477            );
478            assert!(close(got.1, want.1), "h mismatch at {val}");
479            assert!(close(got.2, want.2), "f mismatch at {val}");
480            assert!(close(got.3, want.3), "p mismatch at {val}");
481        }
482    }
483}