aada 0.1.1

纯 Rust 的 2D 骨骼动画引擎,兼容 Spine 4.x 导出格式(JSON + atlas)。求解到分层 Mesh 与各 Mesh 渲染设置为止,之后交给渲染器。
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
//! 运行时聚合体 [`RT`] 与每帧引擎管线。
//!
//! 管线分五步(可用 [`RT::render_frame`] 一把梭,或分步调用以便中间操纵):
//! [`RT::sample`](采样动画)-> [`RT::pose_from_sample`](构建动画后姿态,可改 pose)
//! -> [`RT::constraint_state`](约束状态,可改)-> [`RT::apply_constraint_state`]
//! (按 order 应用 IK/transform)-> [`RT::build_payloads`](蒙皮变形 + 渲染负载)。

use crate::{
    custom_index::AsIndex,
    data::runtime::{
        animate::{
            AnimIdx, AnimateConfig, AnimateSet, SampledAnimate, SampledAttachment,
            SampledSlotValue, SlotChannel,
        },
        constraint::{ConstraintSet, ConstraintState, apply_constraints},
        mesh::{MeshIdx, MeshSet},
        payload::{MeshPayload, PayloadUsage},
        skeleton::{Pose, Skeleton},
        skin::{AttachmentSet, SkinIdx, SkinSet},
        slot::{SlotIdx, SlotSet},
    },
};

/// 一个完整的运行时数据集合,具体使用时还是根据需求使用内部数据,这个结构体主要是为了方便管理和传递
#[derive(Debug, Clone)]
pub struct RT {
    pub skins: SkinSet,
    pub meshs: MeshSet,
    pub skeleton: Skeleton,
    pub slots: SlotSet,
    pub attachments: AttachmentSet,
    pub animates: AnimateSet,
    pub constraints: ConstraintSet,
}

impl RT {
    /// 拆分渲染管线步骤 1:采样动画(骨骼 + slot + 约束 + deform + draw order)
    pub fn sample(&self, anim: AnimIdx, config: &mut AnimateConfig, t: f32) -> SampledAnimate {
        self.animates.sample(anim, config, t)
    }

    /// 拆分渲染管线步骤 2:由采样结果构建动画后 Pose(default_pose + 应用骨骼通道)
    ///
    /// 返回后可在约束前修改 pose(如外部操纵骨骼),再走 [Self::constraint_state] + [Self::apply_constraint_state]
    pub fn pose_from_sample(&self, sampled: &SampledAnimate) -> Pose {
        let mut pose = self.skeleton.default_pose();
        sampled.apply_pose(&mut pose);
        pose
    }

    /// 拆分渲染管线步骤 3:约束状态(骨架默认值 + 动画时间轴覆盖),返回后也可修改
    pub fn constraint_state(&self, sampled: &SampledAnimate) -> ConstraintState {
        let mut state = self.constraints.new_state();
        sampled.apply_constraints(&mut state);
        state
    }

    /// 拆分渲染管线步骤 4:按 order 全局应用约束到 pose(IK / transform)
    pub fn apply_constraint_state(&self, pose: &mut Pose, state: &ConstraintState) {
        apply_constraints(pose, &self.skeleton, &self.constraints, state);
    }

    /// 拆分渲染管线步骤 5:由(约束后)pose + 采样结果生成渲染负载列表
    ///
    /// 皮肤默认 attachment 选择、slot 动画覆盖(attachment 切换 / 颜色)、蒙皮变形、绘制顺序、裁剪指令都在此完成
    pub fn build_payloads(
        &self,
        skins: &[SkinIdx],
        sampled: &SampledAnimate,
        pose: &mut Pose,
    ) -> Vec<MeshPayload> {
        // 期望 attachment:皮肤默认选择 + slot 动画覆盖(attachment 切换 / 隐藏)
        let mut wanted = self.skins.sample(skins, &self.slots, &self.attachments);
        // 动画覆盖的 slot 颜色(t < 首帧时时间轴未生效,不覆盖,用 slot 默认颜色)
        // 按 slot 索引数组(None = 未覆盖),避免每帧 HashMap 分配与 hash 查找
        let mut slot_colors: Vec<Option<[f32; 4]>> = vec![None; self.slots.data.len()];
        for (slot, channel, value) in &sampled.slot_values {
            match (channel, value) {
                (SlotChannel::Attachment, SampledSlotValue::Attachment(SampledAttachment::Set(name))) => {
                    // 动画 attachment 名是逻辑名(解析期已索引化),按 (slot, 皮肤优先级) 查找;找不到视为隐藏
                    let new_att = match name {
                        Some(name_idx) => {
                            let mut found = None;
                            for &sk in skins {
                                if let Some(att) = self.skins.data[sk].get(*slot, *name_idx) {
                                    found = Some(att);
                                    break;
                                }
                            }
                            found
                        }
                        None => None,
                    };
                    wanted.set(*slot, new_att);
                }
                // 官方语义:t < 首帧时 attachment 时间轴未生效,保持皮肤默认 attachment
                (SlotChannel::Attachment, SampledSlotValue::Attachment(SampledAttachment::NotStarted)) => {}
                (SlotChannel::Color, SampledSlotValue::Color(Some(c))) => {
                    slot_colors[slot.as_index()] = Some(*c);
                }
                _ => {}
            }
        }

        // 每个可见 slot:attachment -> 网格(普通网格或裁剪多边形)-> 蒙皮变形 -> payload
        // 裁剪区域:clip 槽到 end 槽之间(不含 end 槽)的网格全部被裁剪,嵌套取交集
        // 迭代顺序 = 绘制顺序:draw order 动画重排(官方语义:drawOrder[i] = setup 索引)
        let mut payloads = Vec::new();
        let mut clip_stack: Vec<MeshIdx> = Vec::new();
        // 一次性建立 slot -> [(deform 所属 mesh, deform 索引)]:deform 时间轴挂在具体 attachment 上,
        // 只对当前显示的网格匹配(官方 timelineAttachment 语义);无 deform 时不分配
        let mut deform_map: Vec<Option<Vec<(MeshIdx, usize)>>> = Vec::new();
        if !sampled.deform_values.is_empty() {
            deform_map.resize(self.slots.data.len(), None);
            for (i, (s, m, _)) in sampled.deform_values.iter().enumerate() {
                deform_map[s.as_index()]
                    .get_or_insert_with(Vec::new)
                    .push((*m, i));
            }
        }
        let wanted_iter: Vec<(
            SlotIdx,
            Option<crate::data::runtime::skin::AttachmentDataIdx>,
        )> = match &sampled.draw_order {
            Some(order) => wanted.iter_with_order(order),
            None => wanted.iter().collect(),
        };
        for (slot, attachment) in wanted_iter {
            // 先处理当前槽的附件:普通网格在裁剪栈状态下生成(end 槽的网格同样被裁剪)
            if let Some(attachment) = attachment {
                let slot_bone = self.slots.get_bone(slot);
                if let Some(mesh_idx) = self.attachments.get_mesh(attachment) {
                    let mesh_data = &self.meshs.data_map[mesh_idx];
                    // deform:动画采样值(加权 = 按 influence 的 delta、非加权 = 绝对位置),
                    // 按 (slot, 网格) 匹配(只取当前显示的 attachment 的 deform)
                    let deform = deform_map
                        .get(slot.as_index())
                        .and_then(|v| v.as_ref())
                        .and_then(|list| list.iter().find(|(m, _)| *m == mesh_idx))
                        .map(|&(_, i)| sampled.deform_values[i].2.as_slice());
                    let positions = match deform {
                        Some(d) => {
                            mesh_data.deform_with_deform(d, pose, &self.skeleton, slot_bone)
                        }
                        None => mesh_data.deform(pose, &self.skeleton, slot_bone),
                    };
                    if let Some(_end_slot) = mesh_data.clip_end_slot {
                        // 裁剪多边形:输出变形后的顶点,进入裁剪区域
                        payloads.push(MeshPayload::new(
                            PayloadUsage::ClipStart { id: mesh_idx },
                            slot,
                            positions,
                            Vec::new(),
                            Vec::new(),
                            [1.0, 1.0, 1.0, 1.0],
                            glam::Affine2::IDENTITY,
                        ));
                        clip_stack.push(mesh_idx);
                    } else {
                        // tint = slot 有效颜色(动画覆盖或 slot 默认色)× attachment 默认颜色(乘性)
                        let slot_color = slot_colors[slot.as_index()]
                            .unwrap_or(self.slots.data[slot].color);
                        let att_color = self.attachments.data[attachment].color;
                        let tint = [
                            slot_color[0] * att_color[0],
                            slot_color[1] * att_color[1],
                            slot_color[2] * att_color[2],
                            slot_color[3] * att_color[3],
                        ];
                        payloads.push(MeshPayload::new(
                            PayloadUsage::Mesh(mesh_idx),
                            slot,
                            positions,
                            mesh_data
                                .uvs
                                .chunks_exact(2)
                                .map(|c| glam::Vec2::new(c[0], c[1]))
                                .collect(),
                            mesh_data.triangles.iter().map(|&i| i as u32).collect(),
                            tint,
                            glam::Affine2::IDENTITY,
                        ));
                    }
                }
            }
            // 到达 end 槽:当前槽网格处理完后,弹出到达终点的裁剪区域(可能多个同时结束)
            while let Some(&clip_mesh) = clip_stack.last() {
                let end = self.meshs.data_map[clip_mesh].clip_end_slot.unwrap();
                if slot.as_index() >= end.as_index() {
                    clip_stack.pop();
                    payloads.push(MeshPayload::new(
                        PayloadUsage::ClipEnd { id: clip_mesh },
                        slot,
                        Vec::new(),
                        Vec::new(),
                        Vec::new(),
                        [1.0, 1.0, 1.0, 1.0],
                        glam::Affine2::IDENTITY,
                    ));
                } else {
                    break;
                }
            }
        }
        // 动画结束时栈里不应残留未关闭的裁剪区域(end 槽缺失时兜底弹出)
        while let Some(clip_mesh) = clip_stack.pop() {
            payloads.push(MeshPayload::new(
                PayloadUsage::ClipEnd { id: clip_mesh },
                unsafe { SlotIdx::from_index(0) },
                Vec::new(),
                Vec::new(),
                Vec::new(),
                [1.0, 1.0, 1.0, 1.0],
                glam::Affine2::IDENTITY,
            ));
        }
        payloads
    }

    /// 每帧渲染接口:采样动画(骨骼 + slot)并生成渲染负载列表
    ///
    /// 输入:皮肤优先级列表(靠前的优先)、动画索引、动画采样器状态(跨帧复用,用于连续采样加速)、时间 t
    /// 输出:MeshPayload 列表,每个 payload 对应一个当前可见的有效网格,列表顺序即渲染顺序
    /// (slot 动画的 attachment 切换与颜色动画已在此应用,渲染层只需消费 payload)
    ///
    /// 需要修改中间结果的调用方(如外部操纵骨骼、覆盖约束参数)请改用拆分步骤:
    /// [RT::sample] -> [RT::pose_from_sample](可修改 pose)-> [RT::constraint_state](可修改约束状态)
    /// -> [RT::apply_constraint_state] -> [RT::build_payloads]
    pub fn render_frame(
        &self,
        skins: &[SkinIdx],
        anim: AnimIdx,
        config: &mut AnimateConfig,
        t: f32,
    ) -> Vec<MeshPayload> {
        let sampled = self.sample(anim, config, t);
        let mut pose = self.pose_from_sample(&sampled);
        let state = self.constraint_state(&sampled);
        self.apply_constraint_state(&mut pose, &state);
        self.build_payloads(skins, &sampled, &mut pose)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{
        custom_index::AsIndex,
        data::runtime::{
            animate::{AnimateSet, AnimationRT, Curve, DeformFrame, DeformTimeline, Keyframe, SlotKeyframe, SlotTimeline, Timeline},
            mesh::{MeshData, MeshIdx, MeshMetadata},
            skeleton::{BoneIdx, Skeleton},
            skin::{AttachmentData, AttachmentNames, Skin},
            slot::{SlotData, SlotIdx},
        },
        utils::default,
    };

    /// 最小 RT:1 骨骼 / 1 slot(默认 attachment "a")/ 2 网格 / 1 皮肤 / 1 动画
    fn build_rt() -> (RT, SkinIdx, AnimIdx) {
        let mut skeleton = Skeleton::new();
        skeleton.insert_bone("root", default()).unwrap();

        // 附件逻辑名 intern 表(1 个 slot)
        let mut names = AttachmentNames::new(1);
        let mut slots = SlotSet::new();
        let slot_idx = slots.insert(
            "s",
            SlotData {
                name: "s".to_string(),
                bone: unsafe { BoneIdx::from_index(0) },
                default_attachment: Some("a".to_string()),
                default_attachment_idx: Some(names.intern(
                    unsafe { SlotIdx::from_index(0) },
                    "a",
                )),
                color: [1.0, 1.0, 1.0, 1.0],
            },
        );
        let _ = slot_idx;

        let mut meshs = MeshSet::new();
        let make_mesh = || MeshData {
            meta: MeshMetadata::default(),
            texture_name: "t".to_string(),
            uvs: vec![0.0, 0.0],
            vertex_count: 1,
            triangles: vec![],
            bones: None,
            // 单顶点受 1 个骨骼影响(无 bones 时取 slot 主骨骼),局部坐标在原点
            influence_counts: vec![1],
            vertices_locals: vec![0.0, 0.0],
            influence_bone_indices: vec![0],
            influence_weights: vec![1.0],
            clip_end_slot: None,
        };
        let mesh_a = meshs.insert_mesh("s_a".to_string(), make_mesh());
        let mesh_b = meshs.insert_mesh("s_b".to_string(), make_mesh());

        let mut attachments = AttachmentSet::new();
        let att_a = attachments.insert(
            "skin_a".to_string(),
            AttachmentData {
                name: "skin_a".to_string(),
                mesh: Some(mesh_a),
                color: [1.0, 1.0, 1.0, 1.0],
            },
        );
        let att_b = attachments.insert(
            "skin_b".to_string(),
            AttachmentData {
                name: "skin_b".to_string(),
                mesh: Some(mesh_b),
                color: [1.0, 1.0, 1.0, 1.0],
            },
        );
        let mut skin = Skin::new();
        skin.insert(slot_idx, names.intern(slot_idx, "a"), att_a);
        skin.insert(slot_idx, names.intern(slot_idx, "b"), att_b);
        let mut skins = SkinSet::new();
        let skin_idx = skins.insert("skin", skin);

        // 动画:骨骼平移 + slot attachment 在 t=0.5 切换到 "b"
        let mut animates = AnimateSet::new();
        let anim_idx = animates.insert(
            "walk",
            AnimationRT {
                name: "walk".to_string(),
                constraint_timelines: vec![],
                deform_timelines: vec![],
                draw_order_timeline: None,
                duration: 1.0,
                timelines: vec![Timeline {
                    bone: unsafe { BoneIdx::from_index(0) },
                    channel: crate::data::runtime::animate::BoneChannel::TranslateX,
                    frames: vec![
                        Keyframe {
                            time: 0.0,
                            value: 0.0,
                            curve: Curve::Linear,
                        },
                        Keyframe {
                            time: 1.0,
                            value: 10.0,
                            curve: Curve::Linear,
                        },
                    ],
                }],
                slot_timelines: vec![SlotTimeline {
                    slot: unsafe { SlotIdx::from_index(0) },
                    channel: SlotChannel::Attachment,
                    frames: vec![
                        SlotKeyframe::Attachment {
                            time: 0.0,
                            name: Some(names.intern(slot_idx, "a")),
                        },
                        SlotKeyframe::Attachment {
                            time: 0.5,
                            name: Some(names.intern(slot_idx, "b")),
                        },
                    ],
                }],
            },
        );

        let rt = RT {
            skins,
            meshs,
            skeleton,
            slots,
            attachments,
            animates,
            constraints: ConstraintSet::default(),
        };
        (rt, skin_idx, anim_idx)
    }

    #[test]
    fn render_frame_delivers_payload_list() {
        let (rt, skin_idx, anim_idx) = build_rt();
        let mut config = AnimateConfig::new();

        // t=0:皮肤默认 attachment "a"(动画也是 "a")-> mesh_a
        let payloads = rt.render_frame(&[skin_idx], anim_idx, &mut config, 0.0);
        assert_eq!(payloads.len(), 1);
        assert_eq!(
            payloads[0].usage,
            PayloadUsage::Mesh(unsafe { MeshIdx::from_index(0) })
        );
        assert_eq!(payloads[0].tint_color, [1.0, 1.0, 1.0, 1.0]);

        // t=0.5:动画 attachment 切换到 "b" -> mesh_b(skin 里逻辑名 "b" 生效)
        let payloads = rt.render_frame(&[skin_idx], anim_idx, &mut config, 0.5);
        assert_eq!(payloads.len(), 1);
        assert_eq!(
            payloads[0].usage,
            PayloadUsage::Mesh(unsafe { MeshIdx::from_index(1) })
        );

        // t=0.5 时骨骼平移插值到 5.0(deform 顶点位置)
        assert!(
            (payloads[0].positions[0].x - 5.0).abs() < 1e-5,
            "x={}",
            payloads[0].positions[0].x
        );
    }

    #[test]
    fn render_frame_slot_color_tint() {
        let (rt, skin_idx, anim_idx) = build_rt();
        let mut config = AnimateConfig::new();
        let payloads = rt.render_frame(&[skin_idx], anim_idx, &mut config, 0.0);
        assert_eq!(payloads.len(), 1);
        // 无颜色动画、默认白 slot/attachment 时 tint 为白色
        assert_eq!(payloads[0].tint_color, [1.0, 1.0, 1.0, 1.0]);
        // 列表顺序即渲染顺序(靠后覆盖靠前)
        assert_eq!(payloads[0].slot.as_index(), 0);
    }

    /// tint 语义(spine 官方):最终 tint = slot 有效颜色 × attachment 颜色,两者乘性继承。
    /// slot 有效颜色 = slot 默认色,被动画 color 时间轴覆盖(t >= 首帧)
    #[test]
    fn render_frame_tint_slot_default_times_attachment() {
        use crate::data::runtime::animate::{Curve, SlotKeyframe, SlotTimeline};
        let (mut rt, skin_idx, anim_idx) = build_rt();
        let slot_idx = unsafe { SlotIdx::from_index(0) };
        // slot 默认色 (1, 0.5, 0, 1) × attachment 色 (0.5, 1, 0.5, 0.8)
        rt.slots.data[slot_idx].color = [1.0, 0.5, 0.0, 1.0];
        let mut names = AttachmentNames::new(rt.slots.data.len());
        let att_a = rt.skins.data[skin_idx]
            .get(slot_idx, names.intern(slot_idx, "a"))
            .unwrap();
        rt.attachments.data[att_a].color = [0.5, 1.0, 0.5, 0.8];
        // attachment b 保持白色
        let att_b = rt.skins.data[skin_idx]
            .get(slot_idx, names.intern(slot_idx, "b"))
            .unwrap();
        let _ = att_b;

        // 动画 color 时间轴首帧在 t=0.25:t<0.25 未生效(用 slot 默认色),t>=0.25 覆盖
        // (注意 t=0.5 动画会把 attachment 切到 "b",与颜色语义无关,避让)
        rt.animates.slot_timelines_mut(anim_idx).push(SlotTimeline {
            slot: slot_idx,
            channel: crate::data::runtime::animate::SlotChannel::Color,
            frames: vec![SlotKeyframe::Color {
                time: 0.25,
                rgba: [0.0, 1.0, 0.0, 1.0],
                curves: [
                    Curve::Linear,
                    Curve::Linear,
                    Curve::Linear,
                    Curve::Linear,
                ],
            }],
        });

        let mut config = AnimateConfig::new();
        // t=0:时间轴未生效 -> slot 默认色 × attachment 色 = (0.5, 0.5, 0, 0.8)
        let payloads = rt.render_frame(&[skin_idx], anim_idx, &mut config, 0.0);
        assert_eq!(payloads[0].tint_color, [0.5, 0.5, 0.0, 0.8]);
        // t=0.25:动画覆盖 slot -> (0,1,0,1) × attachment 色 = (0, 1, 0, 0.8)
        let payloads = rt.render_frame(&[skin_idx], anim_idx, &mut config, 0.25);
        assert_eq!(payloads[0].tint_color, [0.0, 1.0, 0.0, 0.8]);
        // t=0.5:attachment 切到 "b"(白色)-> 动画色 × 白 = (0,1,0,1)
        let payloads = rt.render_frame(&[skin_idx], anim_idx, &mut config, 0.5);
        assert_eq!(payloads[0].tint_color, [0.0, 1.0, 0.0, 1.0]);
    }

    /// clip 场景:clip 槽 -> 被裁剪的网格槽 -> end 槽,payload 流应输出 ClipStart/Mesh/ClipEnd
    /// (clip 是 MeshData 的变体:clip_end_slot 非空、无 UV/三角形)
    #[test]
    fn render_frame_clip_stack() {
        let mut skeleton = Skeleton::new();
        skeleton.insert_bone("root", default()).unwrap();

        // 附件逻辑名 intern 表(3 个 slot)
        let mut names = AttachmentNames::new(3);
        let mut slots = SlotSet::new();
        slots.insert(
            "clip",
            SlotData {
                name: "clip".to_string(),
                bone: unsafe { BoneIdx::from_index(0) },
                default_attachment: Some("c".to_string()),
                default_attachment_idx: Some(names.intern(
                    unsafe { SlotIdx::from_index(0) },
                    "c",
                )),
                color: [1.0, 1.0, 1.0, 1.0],
            },
        );
        slots.insert(
            "mid",
            SlotData {
                name: "mid".to_string(),
                bone: unsafe { BoneIdx::from_index(0) },
                default_attachment: Some("m".to_string()),
                default_attachment_idx: Some(names.intern(
                    unsafe { SlotIdx::from_index(1) },
                    "m",
                )),
                color: [1.0, 1.0, 1.0, 1.0],
            },
        );
        let slot_end = slots.insert(
            "end",
            SlotData {
                name: "end".to_string(),
                bone: unsafe { BoneIdx::from_index(0) },
                default_attachment: Some("e".to_string()),
                default_attachment_idx: Some(names.intern(
                    unsafe { SlotIdx::from_index(2) },
                    "e",
                )),
                color: [1.0, 1.0, 1.0, 1.0],
            },
        );

        let mut meshs = MeshSet::new();
        let make_mesh = || MeshData {
            meta: MeshMetadata::default(),
            texture_name: "t".to_string(),
            uvs: vec![0.0, 0.0],
            vertex_count: 1,
            triangles: vec![],
            bones: None,
            influence_counts: vec![1],
            vertices_locals: vec![0.0, 0.0],
            influence_bone_indices: vec![0],
            influence_weights: vec![1.0],
            clip_end_slot: None,
        };
        // 裁剪多边形也是网格条目(clip_end_slot 标记),id 即 mesh_idx
        let mesh_clip = meshs.insert_mesh(
            "clip_c".to_string(),
            MeshData {
                meta: MeshMetadata::default(),
                texture_name: "c".to_string(),
                uvs: vec![],
                vertex_count: 3,
                triangles: vec![],
                bones: None,
                influence_counts: vec![1, 1, 1],
                vertices_locals: vec![0.0, 0.0, 1.0, 0.0, 1.0, 1.0],
                influence_bone_indices: vec![0, 0, 0],
                influence_weights: vec![1.0, 1.0, 1.0],
                clip_end_slot: Some(slot_end),
            },
        );
        let mesh_mid = meshs.insert_mesh("mid_m".to_string(), make_mesh());
        let mesh_end = meshs.insert_mesh("end_e".to_string(), make_mesh());

        let mut attachments = AttachmentSet::new();
        let att_c = attachments.insert(
            "skin_c".to_string(),
            AttachmentData {
                name: "skin_c".to_string(),
                mesh: Some(mesh_clip),
                color: [1.0, 1.0, 1.0, 1.0],
            },
        );
        let att_m = attachments.insert(
            "skin_m".to_string(),
            AttachmentData {
                name: "skin_m".to_string(),
                mesh: Some(mesh_mid),
                color: [1.0, 1.0, 1.0, 1.0],
            },
        );
        let att_e = attachments.insert(
            "skin_e".to_string(),
            AttachmentData {
                name: "skin_e".to_string(),
                mesh: Some(mesh_end),
                color: [1.0, 1.0, 1.0, 1.0],
            },
        );
        let mut skin = Skin::new();
        skin.insert(
            unsafe { SlotIdx::from_index(0) },
            names.intern(unsafe { SlotIdx::from_index(0) }, "c"),
            att_c,
        );
        skin.insert(
            unsafe { SlotIdx::from_index(1) },
            names.intern(unsafe { SlotIdx::from_index(1) }, "m"),
            att_m,
        );
        skin.insert(
            slot_end,
            names.intern(slot_end, "e"),
            att_e,
        );
        let mut skins = SkinSet::new();
        let skin_idx = skins.insert("skin", skin);

        let mut animates = AnimateSet::new();
        let anim_idx = animates.insert(
            "idle",
            AnimationRT {
                name: "idle".to_string(),
                constraint_timelines: vec![],
                deform_timelines: vec![],
                draw_order_timeline: None,
                duration: 1.0,
                timelines: vec![],
                slot_timelines: vec![],
            },
        );

        let rt = RT {
            skins,
            meshs,
            skeleton,
            slots,
            attachments,
            animates,
            constraints: ConstraintSet::default(),
        };
        let mut config = AnimateConfig::new();
        let payloads = rt.render_frame(&[skin_idx], anim_idx, &mut config, 0.0);

        // 期望流:ClipStart(clip) -> Mesh(mid) -> Mesh(end)(end 槽网格在裁剪栈状态下生成)-> ClipEnd(clip)
        assert_eq!(payloads.len(), 4);
        assert_eq!(payloads[0].usage, PayloadUsage::ClipStart { id: mesh_clip });
        assert_eq!(payloads[0].positions.len(), 3, "多边形顶点随 clip 交付");
        assert_eq!(payloads[1].usage, PayloadUsage::Mesh(mesh_mid));
        assert_eq!(payloads[2].usage, PayloadUsage::Mesh(mesh_end));
        assert_eq!(payloads[3].usage, PayloadUsage::ClipEnd { id: mesh_clip });
    }

    /// build_payloads 的 slot->deform 匹配:同一 slot 同网格多条 deform 时取第一条(与网格匹配的 deform 生效)
    /// mesh_a 无 bones(非加权网格),deform 为绝对位置,root 无变换 -> 顶点直接输出 deform 值
    #[test]
    fn render_frame_deform_map_first_wins() {
        let (mut rt, skin_idx, _) = build_rt();
        // 重建动画:同一 slot 两条 deform timeline(同网格 mesh_a),第一条 (5,7) 第二条 (9,9)
        let mut animates = AnimateSet::new();
        let anim_idx = animates.insert(
            "walk",
            AnimationRT {
                name: "walk".to_string(),
                constraint_timelines: vec![],
                deform_timelines: vec![
                    DeformTimeline {
                        slot: unsafe { SlotIdx::from_index(0) },
                        mesh: unsafe { MeshIdx::from_index(0) },
                        frames: vec![
                            DeformFrame {
                                time: 0.0,
                                vertices: vec![5.0, 7.0],
                                curve: Curve::Linear,
                            },
                            DeformFrame {
                                time: 1.0,
                                vertices: vec![9.0, 11.0],
                                curve: Curve::Linear,
                            },
                        ],
                    },
                    DeformTimeline {
                        slot: unsafe { SlotIdx::from_index(0) },
                        mesh: unsafe { MeshIdx::from_index(0) },
                        frames: vec![
                            DeformFrame {
                                time: 0.0,
                                vertices: vec![9.0, 9.0],
                                curve: Curve::Linear,
                            },
                            DeformFrame {
                                time: 1.0,
                                vertices: vec![1.0, 1.0],
                                curve: Curve::Linear,
                            },
                        ],
                    },
                ],
                draw_order_timeline: None,
                duration: 1.0,
                timelines: vec![],
                slot_timelines: vec![],
            },
        );
        rt.animates = animates;

        let mut config = AnimateConfig::new();
        // t=0:deform_map 命中第一条 deform -> 绝对位置 (5,7)
        let payloads = rt.render_frame(&[skin_idx], anim_idx, &mut config, 0.0);
        assert_eq!(payloads.len(), 1);
        assert!(
            (payloads[0].positions[0].x - 5.0).abs() < 1e-4
                && (payloads[0].positions[0].y - 7.0).abs() < 1e-4,
            "got {:?}",
            payloads[0].positions[0]
        );
        // t=0.5:两 timeline 帧间插值,仍取第一条(0.5*(5,7)+0.5*(9,11)=(7,9))
        let payloads = rt.render_frame(&[skin_idx], anim_idx, &mut config, 0.5);
        assert!(
            (payloads[0].positions[0].x - 7.0).abs() < 1e-4
                && (payloads[0].positions[0].y - 9.0).abs() < 1e-4,
            "got {:?}",
            payloads[0].positions[0]
        );
    }

    /// deform 按 (slot, mesh) 匹配:不同 attachment 的 deform 只作用于各自网格,
    /// 不会把别的 attachment 的形变强加到当前显示的网格上(官方 timelineAttachment 语义)
    #[test]
    fn render_frame_deform_matches_mesh() {
        let (mut rt, skin_idx, _) = build_rt();
        // 重建动画:同一 slot 两条 deform timeline,分别挂 mesh_a / mesh_b
        let mut animates = AnimateSet::new();
        let anim_idx = animates.insert(
            "walk",
            AnimationRT {
                name: "walk".to_string(),
                constraint_timelines: vec![],
                deform_timelines: vec![
                    DeformTimeline {
                        slot: unsafe { SlotIdx::from_index(0) },
                        mesh: unsafe { MeshIdx::from_index(0) }, // mesh_a
                        frames: vec![DeformFrame {
                            time: 0.0,
                            vertices: vec![5.0, 7.0],
                            curve: Curve::Linear,
                        }],
                    },
                    DeformTimeline {
                        slot: unsafe { SlotIdx::from_index(0) },
                        mesh: unsafe { MeshIdx::from_index(1) }, // mesh_b
                        frames: vec![DeformFrame {
                            time: 0.0,
                            vertices: vec![30.0, 40.0],
                            curve: Curve::Linear,
                        }],
                    },
                ],
                draw_order_timeline: None,
                duration: 1.0,
                timelines: vec![],
                slot_timelines: vec![],
            },
        );
        rt.animates = animates;

        let mut config = AnimateConfig::new();
        // t=0:皮肤默认 attachment "a" -> mesh_a,应命中 mesh_a 的 deform (5,7),而非 mesh_b 的 (30,40)
        let payloads = rt.render_frame(&[skin_idx], anim_idx, &mut config, 0.0);
        assert_eq!(payloads.len(), 1);
        assert!(
            (payloads[0].positions[0].x - 5.0).abs() < 1e-4
                && (payloads[0].positions[0].y - 7.0).abs() < 1e-4,
            "got {:?}",
            payloads[0].positions[0]
        );
    }
}