use std::collections::{HashMap, HashSet};
use serde_json::Value;
use crate::{
data::{
runtime::{
animate::{AnimateSet, DEFAULT_CURVE_SAMPLING},
constraint::ConstraintIdx,
mesh::{MeshMetadata, MeshSet},
rt::RT,
skeleton::{InheritMode, Skeleton},
skin::{AttachmentData, AttachmentNames, AttachmentSet, Skin, SkinSet},
slot::SlotSet,
},
store::{
animate::parse_spine_animation, mesh::MeshLocalDataStore, skeleton::BoneDataStore,
slot::SlotDataStore,
},
},
error::Error,
};
#[derive(Debug, Clone)]
pub struct SkinAttachment {
pub skin: String,
pub slot: String,
pub logical: String,
pub actual: String,
pub color: [f32; 4],
}
pub struct SpineImport {
pub bones: Vec<BoneDataStore>,
pub slots: Vec<SlotDataStore>,
pub meshes: Vec<MeshLocalDataStore>,
pub skin_attachments: Vec<SkinAttachment>,
}
pub fn import_spine(root: &Value) -> Result<SpineImport, Error> {
let bones = parse_bones(root)?;
let slots = parse_slots(root)?;
let mut meshes = Vec::new();
let mut skin_attachments = Vec::new();
if let Some(skins) = root.get("skins").and_then(|v| v.as_array()) {
for skin in skins {
let skin_name = skin
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::InvalidData("skin missing name".to_string()))?;
let attachments = skin.get("attachments").and_then(|v| v.as_object());
if let Some(attachments) = attachments {
for (slot_name, slot_attachments) in attachments {
let slot_attachments = slot_attachments.as_object().ok_or_else(|| {
Error::InvalidData(format!(
"skin '{skin_name}' slot '{slot_name}' attachments must be an object"
))
})?;
for (logical_name, att_value) in slot_attachments {
let att_obj = match att_value.as_object() {
Some(o) => o,
None => continue, };
let att_color = attachment_color(att_obj)?;
let att_type = att_obj
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("region");
match att_type {
"region" => {
let mesh = region_to_mesh(slot_name, logical_name, att_value)?;
meshes.push(mesh);
skin_attachments.push(SkinAttachment {
skin: skin_name.to_string(),
slot: slot_name.to_string(),
logical: logical_name.clone(),
actual: logical_name.clone(),
color: att_color,
});
}
"mesh" => {
let actual = att_obj
.get("name")
.and_then(|v| v.as_str())
.unwrap_or(logical_name);
let mesh = convert_spine_mesh_to_local(
skin_name, slot_name, actual, att_value, &bones,
)?;
meshes.push(mesh);
skin_attachments.push(SkinAttachment {
skin: skin_name.to_string(),
slot: slot_name.to_string(),
logical: logical_name.clone(),
actual: actual.to_string(),
color: att_color,
});
}
"clipping" => {
let actual = att_obj
.get("name")
.and_then(|v| v.as_str())
.unwrap_or(logical_name);
let clip = convert_spine_clip_to_local(
skin_name, slot_name, actual, att_value, &bones,
)?;
meshes.push(clip);
skin_attachments.push(SkinAttachment {
skin: skin_name.to_string(),
slot: slot_name.to_string(),
logical: logical_name.clone(),
actual: actual.to_string(),
color: att_color,
});
}
_ => {}
}
}
}
}
}
}
Ok(SpineImport {
bones,
slots,
meshes,
skin_attachments,
})
}
pub fn build_rt(json: &str, atlas_text: &str) -> Result<(RT, Vec<AtlasPage>), Error> {
let root: Value = serde_json::from_str(json)
.map_err(|e| Error::InvalidData(format!("failed to parse spine json: {e}")))?;
let mut import = import_spine(&root)?;
let atlas = parse_atlas(atlas_text)?;
apply_atlas(&mut import.meshes, &atlas)?;
let skeleton = Skeleton::from_bone_data_list(&import.bones)?;
let mut slot_set = SlotSet::new();
for s in &import.slots {
let data = s.clone().to_rt(&skeleton)?;
slot_set.insert(data.name.clone(), data);
}
let att_names = std::cell::RefCell::new(AttachmentNames::new(slot_set.data.len()));
for i in 0..slot_set.data.len() {
let slot_idx = unsafe { crate::custom_index::AsIndex::from_index(i) };
let default = slot_set.data[slot_idx].default_attachment.clone();
slot_set.data[slot_idx].default_attachment_idx =
default.as_deref().map(|n| att_names.borrow_mut().intern(slot_idx, n));
}
let mut mesh_set = MeshSet::new();
let mut attachment_set = AttachmentSet::new();
let mut skin_set = SkinSet::new();
let mut by_skin: HashMap<&str, Vec<&SkinAttachment>> = HashMap::new();
for entry in &import.skin_attachments {
by_skin.entry(entry.skin.as_str()).or_default().push(entry);
}
for (skin_name, entries) in &by_skin {
let mut skin = Skin::new();
for entry in entries {
let slot_name = &entry.slot;
let actual = &entry.actual;
let att_name = format!("{skin_name}_{slot_name}_{actual}");
let att_idx = if let Some(idx) = attachment_set.get_index(&att_name) {
idx
} else {
let mesh_name = format!("{slot_name}_{actual}");
if mesh_set.get_index(&mesh_name).is_none() {
let mesh_store = import
.meshes
.iter()
.find(|m| {
m.meta.name.as_deref() == Some(actual.as_str())
&& m.meta.slot.as_deref() == Some(slot_name.as_str())
})
.cloned();
if let Some(mesh_store) = mesh_store {
let rt = mesh_store.to_rt(&skeleton, &slot_set)?;
mesh_set.insert_mesh(mesh_name.clone(), rt);
}
}
let Some(mesh_idx) = mesh_set.get_index(&mesh_name) else {
continue;
};
attachment_set.insert(
att_name.clone(),
AttachmentData {
name: att_name,
mesh: Some(mesh_idx),
color: entry.color,
},
)
};
let Some(slot_idx) = slot_set.get_index(slot_name) else {
continue;
};
let name_idx = att_names.borrow_mut().intern(slot_idx, &entry.logical);
skin.insert(slot_idx, name_idx, att_idx);
}
skin_set.insert((*skin_name).to_string(), skin);
}
let constraint_store: crate::data::store::constraint::ConstraintDataStore =
serde_json::from_value({
let mut v = serde_json::json!({});
if let Some(ik) = root.get("ik") {
v["ik"] = ik.clone();
}
if let Some(transform) = root.get("transform") {
v["transform"] = transform.clone();
}
v
})
.map_err(|e| Error::InvalidData(format!("failed to parse constraints: {e}")))?;
let mut constraint_indices: HashMap<String, ConstraintIdx> = HashMap::new();
for (i, c) in constraint_store.ik.iter().enumerate() {
constraint_indices.insert(c.name.clone(), ConstraintIdx::Ik(i as u32));
}
for (i, c) in constraint_store.transform.iter().enumerate() {
constraint_indices.insert(c.name.clone(), ConstraintIdx::Transform(i as u32));
}
let constraint_set = constraint_store.to_rt(&skeleton)?;
let mut anim_set = AnimateSet::new();
if let Some(animations) = root.get("animations").and_then(|v| v.as_object()) {
let deform_resolver = |skin_name: &str,
slot_name: &str,
att_name: &str|
-> Option<crate::data::store::animate::DeformMeshInfo> {
let skin_idx = skin_set.get_index(skin_name)?;
let slot_idx = slot_set.get_index(slot_name)?;
let name_idx = att_names.borrow_mut().intern(slot_idx, att_name);
let att = skin_set.data[skin_idx].get(slot_idx, name_idx)?;
let mesh_idx = attachment_set.get_mesh(att)?;
let m = &mesh_set.data_map[mesh_idx];
Some(crate::data::store::animate::DeformMeshInfo {
mesh_idx,
vertex_count: m.vertex_count,
weighted: m.bones.is_some(),
setup: m.vertices_locals.clone(),
deform_len: 2 * m
.influence_counts
.iter()
.map(|&c| c as usize)
.sum::<usize>(),
})
};
for (name, anim) in animations {
let store = parse_spine_animation(name, anim, &import.bones)?;
let mut anim_rt =
store.to_rt(&skeleton, &slot_set, &constraint_indices, &att_names, &deform_resolver)?;
anim_rt.prebuild_curves(DEFAULT_CURVE_SAMPLING);
anim_set.insert(anim_rt.name.clone(), anim_rt);
}
}
let rt = RT {
skins: skin_set,
meshs: mesh_set,
skeleton,
slots: slot_set,
attachments: attachment_set,
animates: anim_set,
constraints: constraint_set,
};
Ok((rt, atlas.pages))
}
#[derive(Debug, Clone, PartialEq)]
pub struct AtlasPage {
pub name: String,
pub width: f32,
pub height: f32,
pub pma: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AtlasRegion {
pub page: usize,
pub name: String,
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
pub offset_x: f32,
pub offset_y: f32,
pub original_width: f32,
pub original_height: f32,
pub degrees: u16,
}
#[derive(Debug, Clone, Default)]
pub struct Atlas {
pub pages: Vec<AtlasPage>,
pub regions: Vec<AtlasRegion>,
}
pub fn parse_atlas(text: &str) -> Result<Atlas, Error> {
let mut atlas = Atlas::default();
let mut lines = text.lines().map(|l| l.trim_end());
let mut page: Option<usize> = None;
let mut region: Option<AtlasRegion> = None;
let mut next = || lines.next();
let mut line = loop {
match next() {
Some(l) if l.trim().is_empty() => continue,
other => break other,
}
};
while let Some(l) = line {
let l = l.trim();
if l.is_empty() {
region = None;
page = None;
line = next();
continue;
}
if page.is_none() {
let name = l.to_string();
let mut page_data = AtlasPage {
name,
width: 0.0,
height: 0.0,
pma: false,
};
loop {
line = next();
match line {
Some(field) if field.contains(':') => {
let (key, value) = split_field(field)?;
match key {
"size" => {
let (w, h) = split_pair(value)?;
page_data.width = w;
page_data.height = h;
}
"pma" => page_data.pma = value == "true",
_ => {} }
}
_ => break,
}
}
atlas.pages.push(page_data);
page = Some(atlas.pages.len() - 1);
continue;
}
if region.is_none() {
let mut r = AtlasRegion {
page: page.unwrap(),
name: l.to_string(),
x: 0.0,
y: 0.0,
width: 0.0,
height: 0.0,
offset_x: 0.0,
offset_y: 0.0,
original_width: 0.0,
original_height: 0.0,
degrees: 0,
};
loop {
line = next();
match line {
Some(field) if field.contains(':') => {
let (key, value) = split_field(field)?;
match key {
"bounds" => {
let (x, y, w, h) = split_quad(value)?;
r.x = x;
r.y = y;
r.width = w;
r.height = h;
}
"offsets" => {
let (ox, oy, ow, oh) = split_quad(value)?;
r.offset_x = ox;
r.offset_y = oy;
r.original_width = ow;
r.original_height = oh;
}
"rotate" => {
r.degrees = if value == "true" {
90
} else if value == "false" {
0
} else {
value.parse::<u16>().unwrap_or(0)
};
}
_ => {} }
}
_ => break,
}
}
if r.original_width == 0.0 && r.original_height == 0.0 {
r.original_width = r.width;
r.original_height = r.height;
}
atlas.regions.push(r);
continue;
}
line = next();
}
Ok(atlas)
}
fn split_field(line: &str) -> Result<(&str, &str), Error> {
let colon = line
.find(':')
.ok_or_else(|| Error::InvalidData(format!("atlas field missing ':' in line: {line}")))?;
Ok((line[..colon].trim(), line[colon + 1..].trim()))
}
fn split_pair(value: &str) -> Result<(f32, f32), Error> {
let mut parts = value.split(',');
let a = parts
.next()
.and_then(|s| s.trim().parse::<f32>().ok())
.ok_or_else(|| Error::InvalidData(format!("atlas expected pair, got: {value}")))?;
let b = parts
.next()
.and_then(|s| s.trim().parse::<f32>().ok())
.ok_or_else(|| Error::InvalidData(format!("atlas expected pair, got: {value}")))?;
Ok((a, b))
}
fn split_quad(value: &str) -> Result<(f32, f32, f32, f32), Error> {
let mut parts = value.split(',');
let a = parts
.next()
.and_then(|s| s.trim().parse::<f32>().ok())
.ok_or_else(|| Error::InvalidData(format!("atlas expected quad, got: {value}")))?;
let b = parts
.next()
.and_then(|s| s.trim().parse::<f32>().ok())
.ok_or_else(|| Error::InvalidData(format!("atlas expected quad, got: {value}")))?;
let c = parts
.next()
.and_then(|s| s.trim().parse::<f32>().ok())
.ok_or_else(|| Error::InvalidData(format!("atlas expected quad, got: {value}")))?;
let d = parts
.next()
.and_then(|s| s.trim().parse::<f32>().ok())
.ok_or_else(|| Error::InvalidData(format!("atlas expected quad, got: {value}")))?;
Ok((a, b, c, d))
}
pub fn region_uvs_to_atlas(region: &AtlasRegion, page: &AtlasPage, local_uvs: &[f32]) -> Vec<f32> {
let page_w = page.width;
let page_h = page.height;
let out: Vec<f32> = local_uvs
.chunks_exact(2)
.flat_map(|uv| {
let (u, v) = match region.degrees {
90 => {
let u = region.x / page_w
- (region.original_height - region.offset_y - region.height) / page_w;
let v = region.y / page_h
- (region.original_width - region.offset_x - region.width) / page_h;
let w = region.original_height / page_w;
let h = region.original_width / page_h;
(u + uv[1] * w, v + (1.0 - uv[0]) * h)
}
180 => {
let u = region.x / page_w
- (region.original_width - region.offset_x - region.width) / page_w;
let v = region.y / page_h - region.offset_y / page_h;
let w = region.original_width / page_w;
let h = region.original_height / page_h;
(u + (1.0 - uv[0]) * w, v + (1.0 - uv[1]) * h)
}
270 => {
let u = region.x / page_w - region.offset_y / page_w;
let v = region.y / page_h - region.offset_x / page_h;
let w = region.original_height / page_w;
let h = region.original_width / page_h;
(u + (1.0 - uv[1]) * w, v + uv[0] * h)
}
_ => {
let u = region.x / page_w - region.offset_x / page_w;
let v = region.y / page_h
- (region.original_height - region.offset_y - region.height) / page_h;
let w = region.original_width / page_w;
let h = region.original_height / page_h;
(u + uv[0] * w, v + uv[1] * h)
}
};
[u, v]
})
.collect();
out
}
pub fn apply_atlas(meshes: &mut [MeshLocalDataStore], atlas: &Atlas) -> Result<(), Error> {
for mesh in meshes {
let Some(uvs) = mesh.uvs.as_mut() else {
continue;
};
let Some(region) = atlas.regions.iter().find(|r| r.name == mesh.texture_name) else {
continue;
};
let page = atlas
.pages
.get(region.page)
.ok_or_else(|| Error::InvalidData("atlas region page out of range".to_string()))?;
*uvs = region_uvs_to_atlas(region, page, uvs);
mesh.texture_name = page.name.clone();
}
Ok(())
}
pub fn build_debug_atlas_pixels(
atlas: &Atlas,
page_index: usize,
seed: u64,
) -> Result<Vec<u8>, Error> {
let page = atlas
.pages
.get(page_index)
.ok_or_else(|| Error::InvalidData(format!("atlas page index {page_index} out of range")))?;
let w = page.width as usize;
let h = page.height as usize;
if w == 0 || h == 0 || w > 16384 || h > 16384 {
return Err(Error::InvalidData(format!(
"atlas page size {w}x{h} out of supported range"
)));
}
let mut pixels = vec![30u8, 30, 30, 255];
pixels = pixels.repeat(w * h);
let mut state = seed;
let mut rand = move || {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(state >> 33) as u8
};
for r in &atlas.regions {
if r.page != page_index {
continue;
}
let x0 = r.x.max(0.0) as usize;
let y0 = r.y.max(0.0) as usize;
let x1 = ((r.x + r.width).min(page.width)) as usize;
let y1 = ((r.y + r.height).min(page.height)) as usize;
let cr = 80 + rand() % 176;
let cg = 80 + rand() % 176;
let cb = 80 + rand() % 176;
for y in y0..y1 {
for x in x0..x1 {
let i = (y * w + x) * 4;
let border = x == x0 || y == y0 || x + 1 == x1 || y + 1 == y1;
if border {
pixels[i] = 255;
pixels[i + 1] = 255;
pixels[i + 2] = 255;
} else {
pixels[i] = cr;
pixels[i + 1] = cg;
pixels[i + 2] = cb;
}
}
}
}
Ok(pixels)
}
fn parse_bones(root: &Value) -> Result<Vec<BoneDataStore>, Error> {
let bones_array = root
.get("bones")
.and_then(|v| v.as_array())
.ok_or_else(|| Error::InvalidData("Spine JSON must contain 'bones' array".to_string()))?;
bones_array.iter().map(spine_bone_to_bone_data).collect()
}
fn spine_bone_to_bone_data(spine_bone: &Value) -> Result<BoneDataStore, Error> {
let obj = spine_bone
.as_object()
.ok_or_else(|| Error::InvalidData("bone must be an object".to_string()))?;
Ok(BoneDataStore {
name: obj
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::InvalidData("bone missing name".to_string()))?
.to_string(),
translate_x: obj.get("x").and_then(|v| v.as_f64()).map(|v| v as f32),
translate_y: obj.get("y").and_then(|v| v.as_f64()).map(|v| v as f32),
rotation_deg: obj
.get("rotation")
.and_then(|v| v.as_f64())
.map(|v| v as f32),
scale_x: obj.get("scaleX").and_then(|v| v.as_f64()).map(|v| v as f32),
scale_y: obj.get("scaleY").and_then(|v| v.as_f64()).map(|v| v as f32),
shear_x_deg: obj.get("shearX").and_then(|v| v.as_f64()).map(|v| v as f32),
shear_y_deg: obj.get("shearY").and_then(|v| v.as_f64()).map(|v| v as f32),
length: obj.get("length").and_then(|v| v.as_f64()).map(|v| v as f32),
inherit_mode: match obj
.get("transform")
.or_else(|| obj.get("inherit"))
.and_then(|v| v.as_str())
{
Some("normal") => Some(InheritMode::Normal),
Some("onlyTranslation") => Some(InheritMode::OnlyTranslation),
Some("noRotationOrReflection") => Some(InheritMode::NoRotationOrReflection),
Some("noScale") => Some(InheritMode::NoScale),
Some("noScaleOrReflection") => Some(InheritMode::NoScaleOrReflection),
Some(other) => {
return Err(Error::InvalidData(format!(
"unknown transform mode: {other}"
)));
}
None => None,
},
default_active: obj.get("skin").and_then(|v| v.as_bool()).map(|b| !b),
parent: obj
.get("parent")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
debug_color: obj
.get("color")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
shear_mode: None,
})
}
fn parse_slots(root: &Value) -> Result<Vec<SlotDataStore>, Error> {
let mut slots = Vec::new();
if let Some(arr) = root.get("slots").and_then(|v| v.as_array()) {
for slot in arr {
let obj = slot
.as_object()
.ok_or_else(|| Error::InvalidData("slot must be an object".to_string()))?;
let name = obj
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::InvalidData("slot missing name".to_string()))?
.to_string();
let bone = obj
.get("bone")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::InvalidData(format!("slot '{name}' missing bone")))?
.to_string();
let default_attachment = obj
.get("attachment")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let color = slot_color(obj)?;
slots.push(SlotDataStore {
name,
bone,
default_attachment,
color,
});
}
}
Ok(slots)
}
fn slot_color(obj: &serde_json::Map<String, serde_json::Value>) -> Result<[f32; 4], Error> {
match obj.get("color").and_then(|v| v.as_str()) {
Some(s) => hex_color_to_4f32(s),
None => Ok([1.0, 1.0, 1.0, 1.0]),
}
}
fn attachment_color(obj: &serde_json::Map<String, serde_json::Value>) -> Result<[f32; 4], Error> {
slot_color(obj)
}
fn hex_color_to_4f32(s: &str) -> Result<[f32; 4], Error> {
let s = s.trim_start_matches('#');
if s.len() != 8 {
return Err(Error::InvalidData(format!(
"color must be 8 hex digits, got '{s}'"
)));
}
let mut out = [0.0f32; 4];
for (i, c) in out.iter_mut().enumerate() {
let byte = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16)
.map_err(|_| Error::InvalidData(format!("invalid hex color '{s}'")))?;
*c = byte as f32 / 255.0;
}
Ok(out)
}
fn region_to_mesh(
slot_name: &str,
attachment_name: &str,
region: &Value,
) -> Result<MeshLocalDataStore, Error> {
let obj = region
.as_object()
.ok_or_else(|| Error::InvalidData("region attachment must be an object".to_string()))?;
let x = obj.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
let y = obj.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
let scale_x = obj.get("scaleX").and_then(|v| v.as_f64()).unwrap_or(1.0) as f32;
let scale_y = obj.get("scaleY").and_then(|v| v.as_f64()).unwrap_or(1.0) as f32;
let rotation = obj.get("rotation").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
let width =
obj.get("width").and_then(|v| v.as_f64()).ok_or_else(|| {
Error::InvalidData(format!("region '{attachment_name}' missing width"))
})? as f32;
let height =
obj.get("height").and_then(|v| v.as_f64()).ok_or_else(|| {
Error::InvalidData(format!("region '{attachment_name}' missing height"))
})? as f32;
let hw = width * 0.5 * scale_x;
let hh = height * 0.5 * scale_y;
let (sin, cos) = rotation.to_radians().sin_cos();
let corners = [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)];
let vertices_locals: Vec<f32> = corners
.iter()
.flat_map(|&(cx, cy)| {
let rx = cx * cos - cy * sin + x;
let ry = cx * sin + cy * cos + y;
[rx, ry]
})
.collect();
Ok(MeshLocalDataStore {
meta: MeshMetadata {
name: Some(attachment_name.to_string()),
slot: Some(slot_name.to_string()),
skin: None,
},
texture_name: obj
.get("path")
.and_then(|v| v.as_str())
.unwrap_or(attachment_name)
.to_string(),
uvs: Some(vec![0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0]),
vertex_count: 4,
triangles: vec![0, 1, 2, 0, 2, 3],
bones: None,
influence_counts: None,
vertices_locals,
influence_bone_indices: None,
influence_weights: None,
clip_end_slot: None,
})
}
type WeightedVertices = (Vec<(f32, f32)>, Vec<u8>, Vec<f32>, Vec<usize>);
fn parse_spine_weighted_vertices(vertices: &[f32]) -> WeightedVertices {
let mut positions = Vec::new();
let mut influence_counts = Vec::new();
let mut influence_weights = Vec::new();
let mut influence_bone_indices = Vec::new();
let mut i = 0;
while i < vertices.len() {
let n = vertices[i] as u8;
i += 1;
influence_counts.push(n);
for _ in 0..n {
let bone_idx = vertices[i] as usize;
let x = vertices[i + 1];
let y = vertices[i + 2];
let weight = vertices[i + 3];
influence_bone_indices.push(bone_idx);
positions.push((x, y));
influence_weights.push(weight);
i += 4;
}
}
(
positions,
influence_counts,
influence_weights,
influence_bone_indices,
)
}
fn convert_spine_mesh_to_local(
skin_name: &str,
slot_name: &str,
attachment_name: &str,
mesh_value: &Value,
global_bones: &[BoneDataStore],
) -> Result<MeshLocalDataStore, Error> {
use std::collections::HashSet;
let mesh_obj = mesh_value
.as_object()
.ok_or_else(|| Error::InvalidData("Mesh attachment is not a JSON object".to_string()))?;
let uvs = mesh_obj
.get("uvs")
.and_then(|v| v.as_array())
.ok_or_else(|| Error::InvalidData("Missing or invalid 'uvs' field".to_string()))?;
let triangles = mesh_obj
.get("triangles")
.and_then(|v| v.as_array())
.ok_or_else(|| Error::InvalidData("Missing or invalid 'triangles' field".to_string()))?;
let vertices = mesh_obj
.get("vertices")
.and_then(|v| v.as_array())
.ok_or_else(|| Error::InvalidData("Missing or invalid 'vertices' field".to_string()))?;
let texture_name = mesh_obj
.get("path")
.and_then(|v| v.as_str())
.or_else(|| mesh_obj.get("name").and_then(|v| v.as_str()))
.unwrap_or(attachment_name)
.to_string();
if uvs.len() % 2 != 0 {
return Err(Error::InvalidData(
"UV array length must be even".to_string(),
));
}
let vertex_count = (uvs.len() / 2) as u32;
if triangles.len() % 3 != 0 {
return Err(Error::InvalidData(
"Triangle array length must be multiple of 3".to_string(),
));
}
let triangles: Vec<u16> = triangles
.iter()
.map(|v| {
v.as_u64()
.map(|i| i as u16)
.ok_or_else(|| Error::InvalidData("Invalid triangle index".to_string()))
})
.collect::<Result<_, _>>()?;
for &idx in &triangles {
if idx as u32 >= vertex_count {
return Err(Error::InvalidData(format!(
"Triangle index {} out of range for {} vertices",
idx, vertex_count
)));
}
}
let uv_vec: Vec<f32> = uvs
.iter()
.map(|v| {
v.as_f64()
.map(|f| f as f32)
.ok_or_else(|| Error::InvalidData("Invalid UV coordinate".to_string()))
})
.collect::<Result<_, _>>()?;
let meta = MeshMetadata {
skin: Some(skin_name.to_string()),
slot: Some(slot_name.to_string()),
name: Some(attachment_name.to_string()),
};
let is_weighted = vertices.len() > uvs.len();
if !is_weighted {
if vertices.len() != uvs.len() {
return Err(Error::InvalidData(
"Non-weighted vertices count doesn't match UV count".to_string(),
));
}
let vertices_locals: Vec<f32> = vertices
.iter()
.map(|v| {
v.as_f64()
.map(|f| f as f32)
.ok_or_else(|| Error::InvalidData("Invalid vertex coordinate".to_string()))
})
.collect::<Result<_, _>>()?;
return Ok(MeshLocalDataStore {
meta,
texture_name,
uvs: Some(uv_vec),
vertex_count,
triangles,
bones: None,
influence_counts: None,
vertices_locals,
influence_bone_indices: None,
influence_weights: None,
clip_end_slot: None,
});
}
let vertices_f32: Vec<f32> = vertices
.iter()
.map(|v| {
v.as_f64()
.map(|f| f as f32)
.ok_or_else(|| Error::InvalidData("Invalid vertex data".to_string()))
})
.collect::<Result<_, _>>()?;
let (positions, influence_counts, influence_weights, influence_bone_indices) =
parse_spine_weighted_vertices(&vertices_f32);
if influence_counts.len() != vertex_count as usize {
return Err(Error::InvalidData(format!(
"Weighted vertex count ({}) doesn't match UV count ({})",
influence_counts.len(),
vertex_count
)));
}
let mut unique_bone_indices = HashSet::new();
for idx in &influence_bone_indices {
if *idx >= global_bones.len() {
return Err(Error::InvalidIndex(format!(
"Bone index {} out of range ({} bones available)",
idx,
global_bones.len()
)));
}
unique_bone_indices.insert(*idx);
}
let bones: Vec<String> = unique_bone_indices
.iter()
.map(|&idx| global_bones[idx].name.clone())
.collect();
let bone_index_map: Vec<usize> = (0..global_bones.len())
.map(|i| {
if unique_bone_indices.contains(&i) {
bones
.iter()
.position(|b| &global_bones[i].name == b)
.unwrap()
} else {
usize::MAX
}
})
.collect();
let mut vertices_locals = Vec::with_capacity(positions.len() * 2);
let mut new_influence_bone_indices = Vec::with_capacity(influence_bone_indices.len());
let mut pos_index = 0;
for &count in &influence_counts {
for _ in 0..count {
vertices_locals.push(positions[pos_index].0);
vertices_locals.push(positions[pos_index].1);
let global_bone_idx = influence_bone_indices[pos_index];
new_influence_bone_indices.push(bone_index_map[global_bone_idx] as u8);
pos_index += 1;
}
}
Ok(MeshLocalDataStore {
meta,
texture_name,
uvs: Some(uv_vec),
vertex_count,
triangles,
bones: Some(bones),
influence_counts: Some(influence_counts),
vertices_locals,
influence_bone_indices: Some(new_influence_bone_indices),
influence_weights: Some(influence_weights),
clip_end_slot: None,
})
}
fn convert_spine_clip_to_local(
skin_name: &str,
slot_name: &str,
attachment_name: &str,
clip_value: &Value,
global_bones: &[BoneDataStore],
) -> Result<MeshLocalDataStore, Error> {
let clip_obj = clip_value
.as_object()
.ok_or_else(|| Error::InvalidData("Clip attachment is not a JSON object".to_string()))?;
let vertex_count = clip_obj
.get("vertexCount")
.and_then(|v| v.as_u64())
.ok_or_else(|| {
Error::InvalidData(format!("clip '{attachment_name}' missing vertexCount"))
})? as u32;
let end_slot = clip_obj
.get("end")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::InvalidData(format!("clip '{attachment_name}' missing end slot")))?
.to_string();
let vertices: Vec<f32> = clip_obj
.get("vertices")
.and_then(|v| v.as_array())
.ok_or_else(|| Error::InvalidData(format!("clip '{attachment_name}' missing vertices")))?
.iter()
.map(|v| {
v.as_f64()
.ok_or_else(|| {
Error::InvalidData(format!("clip '{attachment_name}' vertex must be a number"))
})
.map(|n| n as f32)
})
.collect::<Result<_, _>>()?;
let meta = MeshMetadata {
name: Some(attachment_name.to_string()),
slot: Some(slot_name.to_string()),
skin: Some(skin_name.to_string()),
};
if vertices.len() == vertex_count as usize * 2 {
return Ok(MeshLocalDataStore {
meta,
texture_name: attachment_name.to_string(),
uvs: None,
vertex_count,
triangles: vec![],
bones: None,
influence_counts: None,
vertices_locals: vertices,
influence_bone_indices: None,
influence_weights: None,
clip_end_slot: Some(end_slot),
});
}
let (positions, influence_counts, influence_weights, influence_bone_indices) =
parse_spine_weighted_vertices(&vertices);
if influence_counts.len() != vertex_count as usize {
return Err(Error::InvalidData(format!(
"Weighted clip vertex count ({}) doesn't match vertexCount ({})",
influence_counts.len(),
vertex_count
)));
}
let mut unique_bone_indices = HashSet::new();
for &idx in &influence_bone_indices {
if idx >= global_bones.len() {
return Err(Error::InvalidIndex(format!(
"Bone index {} out of range ({} bones available)",
idx,
global_bones.len()
)));
}
unique_bone_indices.insert(idx);
}
let bones: Vec<String> = unique_bone_indices
.iter()
.map(|&idx| global_bones[idx].name.clone())
.collect();
let bone_index_map: Vec<usize> = (0..global_bones.len())
.map(|i| {
if unique_bone_indices.contains(&i) {
bones
.iter()
.position(|b| &global_bones[i].name == b)
.unwrap()
} else {
usize::MAX
}
})
.collect();
let mut vertices_locals = Vec::with_capacity(positions.len() * 2);
let mut new_influence_bone_indices = Vec::with_capacity(influence_bone_indices.len());
let mut pos_index = 0;
for &count in &influence_counts {
for _ in 0..count {
vertices_locals.push(positions[pos_index].0);
vertices_locals.push(positions[pos_index].1);
let global_bone_idx = influence_bone_indices[pos_index];
new_influence_bone_indices.push(bone_index_map[global_bone_idx] as u8);
pos_index += 1;
}
}
Ok(MeshLocalDataStore {
meta,
texture_name: attachment_name.to_string(),
uvs: None,
vertex_count,
triangles: vec![],
bones: Some(bones),
influence_counts: Some(influence_counts),
vertices_locals,
influence_bone_indices: Some(new_influence_bone_indices),
influence_weights: Some(influence_weights),
clip_end_slot: Some(end_slot),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
custom_index::AsIndex,
data::runtime::{animate::AnimateConfig, clip::Clip, payload::PayloadUsage},
};
#[test]
fn parse_slot_default_color() {
let root = serde_json::json!({
"bones": [ { "name": "root" } ],
"slots": [
{ "name": "plain", "bone": "root" },
{ "name": "tinted", "bone": "root", "color": "#ce3a3aff" }
]
});
let import = import_spine(&root).unwrap();
assert_eq!(import.slots.len(), 2);
assert_eq!(import.slots[0].color, [1.0, 1.0, 1.0, 1.0]);
assert!((import.slots[1].color[0] - 0xce as f32 / 255.0).abs() < 1e-6);
assert!((import.slots[1].color[1] - 0x3a as f32 / 255.0).abs() < 1e-6);
assert_eq!(import.slots[1].color[2], 0x3a as f32 / 255.0);
assert_eq!(import.slots[1].color[3], 1.0);
}
#[test]
fn parse_attachment_default_color() {
let root = serde_json::json!({
"bones": [ { "name": "root" } ],
"slots": [ { "name": "s", "bone": "root", "attachment": "plain" } ],
"skins": [
{ "name": "default", "attachments": {
"s": {
"plain": { "type": "region", "path": "p", "width": 10, "height": 10 },
"tinted": { "type": "region", "path": "p", "width": 10, "height": 10, "color": "ff000080" }
}
} }
]
});
let import = import_spine(&root).unwrap();
let plain = import
.skin_attachments
.iter()
.find(|a| a.logical == "plain")
.unwrap();
let tinted = import
.skin_attachments
.iter()
.find(|a| a.logical == "tinted")
.unwrap();
assert_eq!(plain.color, [1.0, 1.0, 1.0, 1.0]);
assert_eq!(tinted.color, [1.0, 0.0, 0.0, 0x80 as f32 / 255.0]);
}
#[test]
#[ignore = "requires assets files, not in repo"]
fn diagnose_heidi_triangle_winding() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("assets");
let json = std::fs::read_to_string(root.join("HeidiSkin1.skel.json")).unwrap();
let atlas = std::fs::read_to_string(root.join("HeidiSkin1.atlas.txt")).unwrap();
let (rt, _pages) = build_rt(&json, &atlas).unwrap();
let skin_idx = rt.skins.get_index("default").unwrap();
let anim_idx = rt.animates.get_index("Camera_1").unwrap();
let mut config = AnimateConfig::new();
let payloads = rt.render_frame(&[skin_idx], anim_idx, &mut config, 0.0);
let mut mixed = Vec::new();
let mut all_neg = Vec::new();
for p in payloads
.iter()
.filter(|p| matches!(p.usage, PayloadUsage::Mesh(_)))
{
let mut neg = 0usize;
let mut total = 0usize;
for tri in p.indices.chunks_exact(3) {
let a = p.positions[tri[0] as usize];
let b = p.positions[tri[1] as usize];
let c = p.positions[tri[2] as usize];
let s = (b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y);
if s < 0.0 {
neg += 1;
}
total += 1;
}
let name = match p.usage {
PayloadUsage::Mesh(idx) => rt.meshs.data_map[idx]
.meta
.name
.clone()
.unwrap_or_else(|| "?".into()),
_ => unreachable!(),
};
if neg > 0 && neg < total {
mixed.push((name, neg, total));
} else if neg == total {
all_neg.push((name, total));
}
}
eprintln!("mixed winding: {mixed:?}");
eprintln!("all-CW (reversed): {all_neg:?}");
assert!(
mixed.is_empty(),
"混合绕向网格在 bevy 会被剔除一半: {mixed:?}"
);
assert!(
all_neg.is_empty(),
"全 CW 网格在 bevy 完全背面: {all_neg:?}"
);
}
#[test]
#[ignore = "requires assets files, not in repo"]
fn diagnose_heidi_rgba_dark_flash() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("assets");
let json = std::fs::read_to_string(root.join("HeidiSkin1.skel.json")).unwrap();
let atlas = std::fs::read_to_string(root.join("HeidiSkin1.atlas.txt")).unwrap();
let (rt, _pages) = build_rt(&json, &atlas).unwrap();
let skin_idx = rt.skins.get_index("default").unwrap();
let anim_idx = rt.animates.get_index("Camera_1").unwrap();
let color_tl = rt
.animates
.slot_timelines(anim_idx)
.iter()
.filter(|t| t.channel == crate::data::runtime::animate::SlotChannel::Color)
.count();
eprintln!("Camera_1 color timelines: {color_tl}");
assert!(color_tl > 30, "rgba 时间轴应被解析: {color_tl}");
let mut config = AnimateConfig::new();
let dark = rt
.render_frame(&[skin_idx], anim_idx, &mut config, 0.08)
.into_iter()
.filter(|p| matches!(p.usage, PayloadUsage::Mesh(_)))
.filter(|p| p.tint_color[0] < 0.9)
.count();
eprintln!("dark tint payloads at t=0.08: {dark}");
assert!(dark > 20, "拍照变暗帧应使大量网格 tint 变暗: {dark}");
}
#[test]
#[ignore = "requires assets files, not in repo"]
fn diagnose_heidi_shadow_tint() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("assets");
let json = std::fs::read_to_string(root.join("HeidiSkin1.skel.json")).unwrap();
let atlas = std::fs::read_to_string(root.join("HeidiSkin1.atlas.txt")).unwrap();
let (rt, _pages) = build_rt(&json, &atlas).unwrap();
let skin_idx = rt.skins.get_index("default").unwrap();
let anim_idx = rt.animates.get_index("Camera_1").unwrap();
assert_eq!(rt.animates.slot_timeline_count(anim_idx), 40);
let mut config = AnimateConfig::new();
for t in [0.0, 0.5, 1.33] {
let payloads = rt.render_frame(&[skin_idx], anim_idx, &mut config, t);
let shadows: Vec<_> = payloads
.iter()
.filter(|p| matches!(p.usage, PayloadUsage::Mesh(_)))
.map(|p| (rt.slots.data[p.slot].name.clone(), p.tint_color))
.filter(|(n, _)| n.starts_with("Head_Shadow"))
.collect();
eprintln!("t={t}: shadow tints {shadows:?}");
assert!(
shadows.iter().any(|(_, c)| c[3] < 0.5),
"t={t}: 阴影槽应带半透明默认色: {shadows:?}"
);
}
}
#[test]
#[ignore = "requires assets files, not in repo"]
fn diagnose_attachment_color_flow() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("assets");
let json = std::fs::read_to_string(root.join("1.json")).unwrap();
let atlas = std::fs::read_to_string(root.join("1.atlas")).unwrap();
let (rt, _pages) = build_rt(&json, &atlas).unwrap();
let skin_idx = rt.skins.get_index("default").unwrap();
let slot_idx = rt.slots.get_index("Eye_L_Clip").unwrap();
let name_idx = AttachmentNames::new(rt.slots.data.len()).intern(slot_idx, "Eye_L_Clip");
let att = rt.skins.data[skin_idx].get(slot_idx, name_idx).unwrap();
let c = rt.attachments.data[att].color;
assert!((c[0] - 0xce as f32 / 255.0).abs() < 1e-6, "{c:?}");
assert!((c[1] - 0x3a as f32 / 255.0).abs() < 1e-6, "{c:?}");
assert_eq!(c[3], 1.0);
let anim_idx = rt.animates.get_index("Touch_Idle").unwrap();
let mut config = AnimateConfig::new();
let payloads = rt.render_frame(&[skin_idx], anim_idx, &mut config, 0.0);
for p in payloads
.iter()
.filter(|p| matches!(p.usage, PayloadUsage::Mesh(_)))
{
assert_eq!(
p.tint_color,
[1.0, 1.0, 1.0, 1.0],
"{:?}",
rt.slots.data[p.slot].name
);
}
}
#[test]
#[ignore = "requires assets files, not in repo"]
fn diagnose_render_frame_clips() {
let json = std::fs::read_to_string("assets/1.json").unwrap();
let atlas = std::fs::read_to_string("assets/1.atlas").unwrap();
let (rt, _pages) = build_rt(&json, &atlas).unwrap();
let clip_count = rt
.meshs
.data_map
.iter()
.filter(|(_, _, m)| m.clip_end_slot.is_some())
.count();
eprintln!(
"clip meshes: {clip_count} (total {} meshes)",
rt.meshs.data_map.len()
);
assert!(clip_count >= 2, "Aya 应有 2 个眼睛裁剪");
for (_, _, m) in rt.meshs.data_map.iter() {
if let Some(end) = m.clip_end_slot {
eprintln!(
"clip '{}' -> end slot '{}' (idx {})",
m.meta.name.as_deref().unwrap_or("?"),
rt.slots.data[end].name,
end.as_index(),
);
}
}
let skins: Vec<_> = ["default", "Normal"]
.iter()
.filter_map(|n| rt.skins.get_index(n))
.collect();
let anim_idx = rt.animates.get_index("Angry_1").unwrap();
let dur = rt.animates.duration(anim_idx);
let mut config = AnimateConfig::new();
let mut total_starts = 0usize;
let mut total_ends = 0usize;
for i in 0..=40 {
let t = i as f32 / 40.0 * dur;
let payloads = rt.render_frame(&skins, anim_idx, &mut config, t);
let starts = payloads
.iter()
.filter(|p| matches!(p.usage, PayloadUsage::ClipStart { .. }))
.count();
let ends = payloads
.iter()
.filter(|p| matches!(p.usage, PayloadUsage::ClipEnd { .. }))
.count();
assert_eq!(
starts, ends,
"t={t:.2}: ClipStart({starts}) != ClipEnd({ends})"
);
total_starts += starts;
total_ends += ends;
for p in payloads
.iter()
.filter(|p| matches!(p.usage, PayloadUsage::ClipStart { .. }))
{
assert!(p.positions.len() >= 3, "clip 多边形至少 3 个顶点");
}
}
eprintln!("clip frames checked: starts={total_starts} ends={total_ends} over 41 samples");
}
#[test]
#[ignore = "requires assets files, not in repo"]
fn diagnose_constraints_effect() {
use crate::data::runtime::constraint::{ConstraintIdx, apply_constraints};
let json = std::fs::read_to_string("assets/1.json").unwrap();
let atlas = std::fs::read_to_string("assets/1.atlas").unwrap();
let (rt, _pages) = build_rt(&json, &atlas).unwrap();
eprintln!(
"constraints: {} ik, {} transform (order {} items)",
rt.constraints.ik.len(),
rt.constraints.transform.len(),
rt.constraints.order.len()
);
assert_eq!(rt.constraints.ik.len(), 4, "Aya 应有 4 个 IK 约束");
assert!(!rt.constraints.transform.is_empty());
let state = rt.constraints.new_state();
for (i, c) in rt.constraints.ik.iter().enumerate() {
let name0 = rt.skeleton.data_static.get_name(c.bones[0]).unwrap_or("?");
eprintln!("ik[{i}] '{name0}' 默认 mix = {}", state.ik[i].mix);
}
let mut pose = rt.skeleton.default_pose();
apply_constraints(&mut pose, &rt.skeleton, &rt.constraints, &state);
let anim_idx = rt.animates.get_index("Angry_1").unwrap();
let anim = rt.animates.slot_timelines(anim_idx); let _ = anim;
let mut config = crate::data::runtime::animate::AnimateConfig::new();
let sampled = rt.animates.sample(anim_idx, &mut config, 0.5);
eprintln!(
"Angry_1 t=0.5 constraint values: {}",
sampled.constraint_values.len()
);
for (idx, frame) in &sampled.constraint_values {
match (idx, frame) {
(
ConstraintIdx::Ik(i),
crate::data::runtime::animate::ConstraintKeyframe::Ik {
mix,
bend_positive,
..
},
) => {
eprintln!(
" ik[{}] mix={:?} bend_positive={:?}",
i, mix, bend_positive
);
}
(
ConstraintIdx::Transform(i),
crate::data::runtime::animate::ConstraintKeyframe::Transform {
mix_rotate,
mix_x,
..
},
) => {
eprintln!(
" transform[{}] mix_rotate={:?} mix_x={:?}",
i, mix_rotate, mix_x
);
}
_ => {}
}
}
let mut state = rt.constraints.new_state();
sampled.apply_constraints(&mut state);
let mut pose = rt.skeleton.default_pose();
apply_constraints(&mut pose, &rt.skeleton, &rt.constraints, &state);
eprintln!("constraints applied ok (no panic)");
for (i, c) in rt.constraints.ik.iter().enumerate() {
let names: Vec<String> = c
.bones
.iter()
.map(|b| {
rt.skeleton
.data_static
.get_name(*b)
.unwrap_or("?")
.to_string()
})
.collect();
let tgt = rt
.skeleton
.data_static
.get_name(c.target)
.unwrap_or("?")
.to_string();
eprintln!(
"ik[{i}] bones={names:?} target={tgt} mix={} bendPositive={} order={}",
c.mix, c.bend_positive, c.order
);
}
for (i, c) in rt.constraints.transform.iter().enumerate() {
let names: Vec<String> = c
.bones
.iter()
.map(|b| {
rt.skeleton
.data_static
.get_name(*b)
.unwrap_or("?")
.to_string()
})
.collect();
let tgt = rt
.skeleton
.data_static
.get_name(c.target)
.unwrap_or("?")
.to_string();
eprintln!(
"transform[{i}] bones={names:?} target={tgt} mixR={} mixX={} mixY={} mixSX={} mixSY={} mixShY={} offR={} offX={} offY={} offSX={} offSY={} order={}",
c.mix_rotate,
c.mix_x,
c.mix_y,
c.mix_scale_x,
c.mix_scale_y,
c.mix_shear_y,
c.offset_rotation,
c.offset_x,
c.offset_y,
c.offset_scale_x,
c.offset_scale_y,
c.order
);
}
let mut bad = 0;
for i in 0..rt.skeleton.data_static.len() {
let d = &rt.skeleton.data_static
[unsafe { crate::data::runtime::skeleton::BoneIdx::from_index(i) }];
bad += 1;
if bad <= 8 {
eprintln!(
"non-unit scale bone '{}' sx={} sy={}",
rt.skeleton
.data_static
.get_name(unsafe { crate::data::runtime::skeleton::BoneIdx::from_index(i) })
.unwrap_or("?"),
d.scale_x,
d.scale_y
);
}
}
eprintln!("non-unit scale bones total: {bad}");
}
#[test]
#[ignore = "requires assets files, not in repo"]
fn dump_bones_for_official_comparison() {
let json = std::fs::read_to_string("assets/1.json").unwrap();
let atlas = std::fs::read_to_string("assets/1.atlas").unwrap();
let (rt, _pages) = build_rt(&json, &atlas).unwrap();
let anim_idx = rt.animates.get_index("Angry_1").unwrap();
let mut config = crate::data::runtime::animate::AnimateConfig::new();
let mut pose = rt.skeleton.default_pose();
let t = std::env::var("DUMP_T")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0.5);
let sampled = rt.animates.sample(anim_idx, &mut config, t);
sampled.apply_pose(&mut pose);
let mut state = rt.constraints.new_state();
sampled.apply_constraints(&mut state);
crate::data::runtime::constraint::apply_constraints(
&mut pose,
&rt.skeleton,
&rt.constraints,
&state,
);
let mut out = String::from("[\n");
for i in 0..rt.skeleton.data_static.len() {
let w = pose.get_world_affine(&rt.skeleton, unsafe {
crate::data::runtime::skeleton::BoneIdx::from_index(i)
});
let name = rt
.skeleton
.data_static
.get_name(unsafe { crate::data::runtime::skeleton::BoneIdx::from_index(i) })
.unwrap_or("?");
let rot = w.matrix2.x_axis.y.atan2(w.matrix2.x_axis.x).to_degrees();
out.push_str(&format!(
" {{\"name\":\"{name}\",\"x\":{:.3},\"y\":{:.3},\"rotation\":{:.3}}},\n",
w.translation.x, w.translation.y, rot
));
}
out.push_str("]\n");
std::fs::write("R:/Temp/opencode/our_dump.json", out).unwrap();
let mut do_map = serde_json::Map::new();
for name in rt.animates.keys() {
let anim_idx = rt.animates.get_index(name).unwrap();
let mut config = crate::data::runtime::animate::AnimateConfig::new();
let mut orders = serde_json::Map::new();
for t in [0.0f32, 0.5, 1.0] {
let sampled = rt.animates.sample(anim_idx, &mut config, t);
let order = sampled.draw_order.map(|o| {
o.iter()
.map(|&i| {
rt.slots
.data
.get_name(unsafe {
crate::data::runtime::slot::SlotIdx::from_index(i as usize)
})
.unwrap_or("?")
.to_string()
})
.collect::<Vec<_>>()
});
orders.insert(format!("{t}"), serde_json::json!(order));
}
if orders.values().any(|v| v.as_array().is_some()) {
do_map.insert(name.to_string(), serde_json::Value::Object(orders));
}
}
std::fs::write(
"R:/Temp/opencode/our_draworder.json",
serde_json::to_string_pretty(&serde_json::Value::Object(do_map)).unwrap(),
)
.unwrap();
eprintln!("dumped to R:/Temp/opencode/our_dump.json");
}
#[test]
#[ignore = "requires external test data"]
fn diagnose_deform_matches_official() {
let json = std::fs::read_to_string("R:/Temp/opencode/deform_test.json").unwrap();
let atlas = "m.png\nsize: 8, 8\nformat: RGBA8888\nfilter: Linear, Linear\nrepeat: none\nm\n rotate: false\n xy: 0, 0\n size: 8, 8\n orig: 8, 8\n offset: 0, 0\n index: -1\n";
let (rt, _pages) = build_rt(&json, atlas).unwrap();
let anim_idx = rt.animates.get_index("deform_anim").unwrap();
let skin_idx = rt.skins.get_index("default").unwrap();
let mut config = crate::data::runtime::animate::AnimateConfig::new();
for (t, expect) in [
(
0.0f32,
vec![10.0, 20.0, 130.0, 40.0, 150.0, 160.0, 70.0, 180.0],
),
(0.5, vec![5.0, 10.0, 115.0, 20.0, 125.0, 130.0, 35.0, 140.0]),
(1.0, vec![0.0, 0.0, 100.0, 0.0, 100.0, 100.0, 0.0, 100.0]),
] {
let payloads = rt.render_frame(&[skin_idx], anim_idx, &mut config, t);
let slot_idx = rt.slots.get_index("s").unwrap();
let pos = payloads
.iter()
.find(|p| p.slot == slot_idx)
.map(|p| p.positions.clone())
.unwrap();
for (i, b) in expect.iter().enumerate() {
if i % 2 != 0 {
continue;
}
let (x, y) = (pos[i / 2].x, pos[i / 2].y);
let (ex, ey) = (b, expect[i + 1]);
assert!(
(x - ex).abs() < 0.01 && (y - ey).abs() < 0.01,
"t={t} vertex {}: ours ({x},{y}), official ({ex},{ey})",
i / 2
);
}
}
eprintln!("deform matches official (unweighted, curve interpolation)");
}
#[test]
#[ignore = "requires assets files, not in repo"]
fn diagnose_grab_slot_rendering() {
let json = std::fs::read_to_string("assets/1.json").unwrap();
let atlas = std::fs::read_to_string("assets/1.atlas").unwrap();
let (rt, _pages) = build_rt(&json, &atlas).unwrap();
let skins: Vec<_> = ["default", "Normal"]
.iter()
.filter_map(|n| rt.skins.get_index(n))
.collect();
let mut names = AttachmentNames::new(rt.slots.data.len());
for &sk in &skins {
for (slot_name, logical) in [
("Sleeve_L_1_Front", "Sleeve_L_1_Front"),
("Sleeve_L_1_Front_Grab", "Sleeve_L_1_Front"),
("Sleeve_L_1_Front_Grab2", "Sleeve_L_1_Front"),
] {
let Some(slot_idx) = rt.slots.get_index(slot_name) else {
continue;
};
let name_idx = names.intern(slot_idx, logical);
if let Some(att) = rt.skins.data[sk].get(slot_idx, name_idx) {
let mesh_idx = rt.attachments.get_mesh(att).unwrap();
let m = &rt.meshs.data_map[mesh_idx];
eprintln!(
"skin get('{slot_name}','{logical}') -> mesh slot='{}' name='{}'",
m.meta.slot.as_deref().unwrap_or("?"),
m.meta.name.as_deref().unwrap_or("?"),
);
}
}
}
let anim_idx = rt.animates.get_index("Angry_1").unwrap();
let mut config = AnimateConfig::new();
let payloads = rt.render_frame(&skins, anim_idx, &mut config, 0.0);
for p in &payloads {
let slot_name = &rt.slots.data[p.slot].name;
if slot_name.contains("Sleeve") {
let mesh_name = match p.usage {
PayloadUsage::Mesh(idx) => {
let m = &rt.meshs.data_map[idx];
format!(
"slot='{}' name='{}'",
m.meta.slot.as_deref().unwrap_or("?"),
m.meta.name.as_deref().unwrap_or("?")
)
}
PayloadUsage::ClipStart { id } => format!(
"CLIP slot='{}' name='{:?}'",
rt.meshs.data_map[id].meta.slot.as_deref().unwrap_or("?"),
rt.meshs.data_map[id].meta.name
),
PayloadUsage::ClipEnd { .. } => "ClipEnd".to_string(),
};
eprintln!("slot '{slot_name}' -> mesh '{mesh_name}'");
}
}
}
#[test]
#[ignore = "requires assets files, not in repo"]
fn diagnose_software_clipping_effect() {
use glam::Vec2;
fn point_in_polygon(p: Vec2, poly: &[Vec2]) -> bool {
let n = poly.len();
for i in 0..n {
let a = poly[i];
let b = poly[(i + 1) % n];
let ab = b - a;
let len2 = ab.dot(ab);
if len2 < 1e-12 {
continue;
}
let t = (p - a).dot(ab) / len2;
if (0.0..=1.0).contains(&t) {
let d = (a + ab * t - p).length();
if d <= 1e-2 {
return true;
}
}
}
let mut inside = false;
for i in 0..n {
let a = poly[i];
let b = poly[(i + 1) % n];
if (a.y > p.y) != (b.y > p.y) {
let x = a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y);
if p.x < x {
inside = !inside;
}
}
}
inside
}
let json = std::fs::read_to_string("assets/1.json").unwrap();
let atlas = std::fs::read_to_string("assets/1.atlas").unwrap();
let (rt, _pages) = build_rt(&json, &atlas).unwrap();
let skins: Vec<_> = ["default", "Normal"]
.iter()
.filter_map(|n| rt.skins.get_index(n))
.collect();
let anim_idx = rt.animates.get_index("Angry_1").unwrap();
let dur = rt.animates.duration(anim_idx);
let mut config = AnimateConfig::new();
let mut cut_meshes = 0usize;
let mut max_verts = 0usize;
let mut clipped_meshes_seen = 0usize;
let mut names_seen: Vec<String> = Vec::new();
for i in 0..=40 {
let t = i as f32 / 40.0 * dur;
let raw = rt.render_frame(&skins, anim_idx, &mut config, t);
let mut mesh_polys: Vec<Option<Vec<Vec2>>> = Vec::new();
let mut stack: Vec<Vec<Vec2>> = Vec::new();
for p in &raw {
match p.usage {
PayloadUsage::ClipStart { .. } => stack.push(p.positions.clone()),
PayloadUsage::ClipEnd { .. } => {
stack.pop();
}
PayloadUsage::Mesh(_) => mesh_polys.push(stack.last().cloned()),
}
}
let mut clipped = raw;
clipped.clip_software();
let mut mi = 0usize;
for p in &clipped {
let PayloadUsage::Mesh(mesh_idx) = p.usage else {
continue;
};
let Some(Some(clip_poly)) = mesh_polys.get(mi) else {
mi += 1;
continue;
};
mi += 1;
let meta = &rt.meshs.data_map[mesh_idx].meta;
let name = meta.name.as_deref().unwrap_or("");
if !names_seen.contains(&name.to_string()) {
names_seen.push(name.to_string());
}
if name.contains("Eye") {
clipped_meshes_seen += 1;
for v in &p.positions {
assert!(
point_in_polygon(*v, clip_poly),
"t={t:.2} Eye mesh '{name}' 顶点 {v:?} 在裁剪区域外"
);
}
if p.positions.len() > 4 {
cut_meshes += 1;
max_verts = max_verts.max(p.positions.len());
}
}
}
}
assert!(
clipped_meshes_seen > 0,
"未找到 Eye mesh payload;裁剪区间的网格: {names_seen:?}"
);
assert!(cut_meshes > 0, "没有任何 Eye mesh 被裁剪切碎");
eprintln!(
"software clipping effect: {clipped_meshes_seen} eye meshes clipped, {cut_meshes} cut, max verts {max_verts} (original 4)"
);
}
#[test]
#[ignore = "requires assets/1.json (spine export), not in repo"]
fn import_real_spine_file() {
let file = std::fs::read_to_string("assets/1.json").unwrap();
let root: Value = serde_json::from_str(&file).unwrap();
let import = import_spine(&root).unwrap();
assert!(!import.bones.is_empty());
assert!(!import.slots.is_empty());
assert!(!import.meshes.is_empty());
assert!(!import.skin_attachments.is_empty());
eprintln!(
"bones={} slots={} meshes={} skin_attachments={}",
import.bones.len(),
import.slots.len(),
import.meshes.len(),
import.skin_attachments.len()
);
for m in &import.meshes {
assert!(
m.vertex_count as usize * 2 == m.vertices_locals.len()
|| m.influence_counts.is_some()
);
}
}
#[test]
#[ignore = "requires assets/1.atlas (spine export), not in repo"]
fn parse_real_atlas() {
let file = std::fs::read_to_string("assets/1.atlas").unwrap();
let atlas = parse_atlas(&file).unwrap();
assert!(!atlas.pages.is_empty());
assert!(!atlas.regions.is_empty());
let ball = atlas.regions.iter().find(|r| r.name == "Ball_L").unwrap();
assert_eq!(ball.x, 1771.0);
assert_eq!(ball.y, 726.0);
assert_eq!(ball.width, 85.0);
assert_eq!(ball.height, 73.0);
assert_eq!(ball.offset_x, 1.0);
assert_eq!(ball.offset_y, 0.0);
assert_eq!(ball.original_width, 87.0);
assert_eq!(ball.original_height, 73.0);
assert_eq!(ball.degrees, 0);
assert_eq!(atlas.pages[ball.page].name, "Aya.png");
assert_eq!(atlas.pages[ball.page].width, 2048.0);
assert_eq!(atlas.pages[ball.page].height, 1024.0);
assert!(
atlas.pages[ball.page].pma,
"1.atlas 的 Aya.png 页是 pma:true"
);
}
#[test]
#[ignore = "requires assets/1.json + 1.atlas (spine export), not in repo"]
fn apply_atlas_converts_uvs_to_atlas_coords() {
let json = std::fs::read_to_string("assets/1.json").unwrap();
let root: Value = serde_json::from_str(&json).unwrap();
let atlas = parse_atlas(&std::fs::read_to_string("assets/1.atlas").unwrap()).unwrap();
let mut import = import_spine(&root).unwrap();
apply_atlas(&mut import.meshes, &atlas).unwrap();
let ball = atlas.regions.iter().find(|r| r.name == "Ball_L").unwrap();
let page = &atlas.pages[ball.page];
let u_min = ball.x / page.width;
let u_max = (ball.x + ball.width) / page.width;
let v_min = ball.y / page.height;
let v_max = (ball.y + ball.height) / page.height;
let mesh = import
.meshes
.iter()
.find(|m| m.texture_name == page.name && m.uvs.is_some())
.expect("no converted Ball_L mesh");
for uv in mesh.uvs.as_ref().unwrap().chunks_exact(2) {
assert!(
uv[0] >= u_min - 1e-4 && uv[0] <= u_max + 1e-4,
"u={}",
uv[0]
);
assert!(
uv[1] >= v_min - 1e-4 && uv[1] <= v_max + 1e-4,
"v={}",
uv[1]
);
}
eprintln!(
"atlas: pages={} regions={}, converted meshes={}",
atlas.pages.len(),
atlas.regions.len(),
import.meshes.len()
);
}
#[test]
fn atlas_uv_rotation_branches() {
let page = AtlasPage {
name: "p.png".into(),
width: 100.0,
height: 100.0,
pma: false,
};
let region = AtlasRegion {
page: 0,
name: "r".into(),
x: 10.0,
y: 20.0,
width: 30.0,
height: 40.0,
offset_x: 0.0,
offset_y: 0.0,
original_width: 40.0,
original_height: 30.0,
degrees: 90,
};
let out = region_uvs_to_atlas(®ion, &page, &[0.0, 0.0, 1.0, 1.0]);
assert!((out[0] - 0.2).abs() < 1e-4 && (out[1] - 0.5).abs() < 1e-4);
assert!((out[2] - 0.5).abs() < 1e-4 && (out[3] - 0.1).abs() < 1e-4);
let region0 = AtlasRegion {
degrees: 0,
width: 30.0,
height: 40.0,
original_width: 30.0,
original_height: 40.0,
..region
};
let out = region_uvs_to_atlas(®ion0, &page, &[0.0, 0.0, 1.0, 1.0]);
assert!((out[0] - 0.1).abs() < 1e-4 && (out[1] - 0.2).abs() < 1e-4);
assert!((out[2] - 0.4).abs() < 1e-4 && (out[3] - 0.6).abs() < 1e-4);
}
#[test]
fn debug_atlas_pixels_shape_and_colors() {
let page = AtlasPage {
name: "p.png".into(),
width: 100.0,
height: 100.0,
pma: false,
};
let atlas = Atlas {
pages: vec![page],
regions: vec![AtlasRegion {
page: 0,
name: "r".into(),
x: 10.0,
y: 20.0,
width: 30.0,
height: 40.0,
offset_x: 0.0,
offset_y: 0.0,
original_width: 30.0,
original_height: 40.0,
degrees: 0,
}],
};
let pixels = build_debug_atlas_pixels(&atlas, 0, 42).unwrap();
assert_eq!(pixels.len(), 100 * 100 * 4);
let px = |x: usize, y: usize| -> [u8; 4] {
let i = (y * 100 + x) * 4;
[pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3]]
};
assert_eq!(px(0, 0), [30, 30, 30, 255]);
assert_eq!(px(10, 20), [255, 255, 255, 255]);
assert_eq!(px(39, 59), [255, 255, 255, 255]);
let inner = px(25, 40);
assert_ne!(inner, [30, 30, 30, 255]);
assert_ne!(inner, [255, 255, 255, 255]);
assert_eq!(inner[3], 255);
let again = build_debug_atlas_pixels(&atlas, 0, 42).unwrap();
assert_eq!(pixels, again);
}
}