Skip to content

Commit d8bc050

Browse files
authored
Exporter, version 1
1 parent 3e8c1a4 commit d8bc050

9 files changed

+1427
-0
lines changed

Output/SPINE_Output_animation.py

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
# SPINE_Output_animation.py
2+
#
3+
# Exports Blender F-Curves (no baking) to Spine JSON "animations".
4+
# - Preserves Bezier shapes using Blender keyframe handles.
5+
# - Matches Spine 4.3 curve encoding used by Spine sample files:
6+
# * Rotate (single channel): 4-number curve [cx1, cy1, cx2, cy2]
7+
# * Translate/Scale (two channels): 8-number curve [cx1x, cy1x, cx2x, cy2x, cx1y, cy1y, cx2y, cy2y]
8+
# All control points are in ABSOLUTE SECONDS (X) and VALUE UNITS (Y).
9+
#
10+
# Supported: bone translate(x,y), rotate, scale(x,y)
11+
# Source: arm.animation_data.action
12+
13+
import bpy
14+
import math
15+
16+
AXIS_MAP = {
17+
# TRANSLATE: use Blender X -> Spine x, Blender Y -> Spine y (was Y=2/Z; now Y=1)
18+
"loc_to_spine": {"x_index": 1, "y_index": 0},
19+
20+
# ROTATE: leave as you had it (Euler Z)
21+
"rot_euler_index": 2,
22+
23+
# SCALE: unchanged (X/Z -> Spine x/y). Adjust if your Y scale should drive Spine y.
24+
"scale_to_spine": {"x_index": 0, "y_index": 2},
25+
26+
"per_bone_rot_euler_index": {}
27+
}
28+
29+
# --- helpers ---------------------------------------------------------------
30+
31+
def _scene_seconds(frame: float, fps: float) -> float:
32+
return float(frame) / float(fps) if fps > 0 else float(frame)
33+
34+
def _knot_map_by_frame(fc):
35+
return {int(round(k.co.x)): k for k in fc.keyframe_points}
36+
37+
def _handles_to_curve_abs_seconds(k_i, k_j, fps, value_xform):
38+
if k_i is None or k_j is None:
39+
return None
40+
if (k_i.interpolation != 'BEZIER') or (k_j.interpolation != 'BEZIER'):
41+
return None
42+
43+
ti_f, vi = k_i.co
44+
tj_f, vj = k_j.co
45+
hi_t_f, hi_v = k_i.handle_right
46+
hj_t_f, hj_v = k_j.handle_left
47+
48+
if abs(tj_f - ti_f) <= 1e-12:
49+
return None # zero-length time
50+
51+
def vx(v):
52+
return float(value_xform(v)) if value_xform else float(v)
53+
54+
ti = _scene_seconds(ti_f, fps)
55+
tj = _scene_seconds(tj_f, fps)
56+
cx1 = _scene_seconds(hi_t_f, fps)
57+
cy1 = vx(hi_v)
58+
cx2 = _scene_seconds(hj_t_f, fps)
59+
cy2 = vx(hj_v)
60+
61+
if abs(vj - vi) <= 1e-12:
62+
return None
63+
64+
return [round(cx1, 6), round(cy1, 6), round(cx2, 6), round(cy2, 6)]
65+
66+
def _emit_channel_timelines(fcurves, to_seconds, value_xform_per_channel):
67+
if not fcurves:
68+
return [], False
69+
70+
fps = bpy.context.scene.render.fps
71+
frames = set()
72+
for fc in fcurves:
73+
for kp in fc.keyframe_points:
74+
frames.add(int(round(kp.co.x)))
75+
frames = sorted(frames)
76+
if not frames:
77+
return [], False
78+
79+
knotmaps = [_knot_map_by_frame(fc) for fc in fcurves]
80+
81+
keys = []
82+
stepped_only = True
83+
84+
for idx, f in enumerate(frames):
85+
t_sec = to_seconds(f)
86+
vals = []
87+
for ch, fc in enumerate(fcurves):
88+
v = fc.evaluate(f)
89+
xform = value_xform_per_channel[ch] if (value_xform_per_channel and ch < len(value_xform_per_channel)) else (lambda x: x)
90+
vals.append(xform(v))
91+
92+
rec = {"time": round(t_sec, 6), "_vals": [float(v) for v in vals]}
93+
94+
if idx < len(frames) - 1:
95+
nxt = frames[idx + 1]
96+
k_i_ref = knotmaps[0].get(f)
97+
k_j_ref = knotmaps[0].get(nxt)
98+
if k_i_ref and k_i_ref.interpolation == 'CONSTANT':
99+
rec["curve"] = "stepped"
100+
else:
101+
if len(fcurves) == 1:
102+
c = _handles_to_curve_abs_seconds(
103+
k_i_ref, k_j_ref, fps,
104+
value_xform_per_channel[0] if value_xform_per_channel else None
105+
)
106+
if c:
107+
rec["curve"] = c
108+
stepped_only = False
109+
else:
110+
k_i_x = knotmaps[0].get(f); k_j_x = knotmaps[0].get(nxt)
111+
k_i_y = knotmaps[1].get(f); k_j_y = knotmaps[1].get(nxt)
112+
cx = _handles_to_curve_abs_seconds(
113+
k_i_x, k_j_x, fps,
114+
value_xform_per_channel[0] if value_xform_per_channel else None
115+
)
116+
cy = _handles_to_curve_abs_seconds(
117+
k_i_y, k_j_y, fps,
118+
value_xform_per_channel[1] if value_xform_per_channel else None
119+
)
120+
if cx and cy:
121+
rec["curve"] = [*cx, *cy]
122+
stepped_only = False
123+
keys.append(rec)
124+
125+
return keys, stepped_only
126+
127+
def _pick_action(arm):
128+
return getattr(arm.animation_data, "action", None)
129+
130+
def _fcurves_for_path(action, data_path_prefix, index_whitelist=None):
131+
out = []
132+
if not action:
133+
return out
134+
for fc in action.fcurves:
135+
if not fc.data_path.startswith(data_path_prefix):
136+
continue
137+
if index_whitelist is not None and fc.array_index not in index_whitelist:
138+
continue
139+
out.append(fc)
140+
return out
141+
142+
def _bone_path(bone_name, prop):
143+
return f'pose.bones["{bone_name}"].{prop}'
144+
145+
# --- public API --------------------------------------------------------------
146+
147+
def build_animations(arm, SCALE, toFrame, headF, tailF, angleF, basis_from_parent, project_to_basis_px):
148+
anim = {}
149+
action = _pick_action(arm)
150+
if not action:
151+
return anim
152+
153+
fps = bpy.context.scene.render.fps
154+
to_seconds = lambda f: _scene_seconds(f, fps)
155+
156+
anim_name = action.name or "Action"
157+
anim_block = {}
158+
bones_block = {}
159+
160+
per_bone_rot = AXIS_MAP.get("per_bone_rot_euler_index", {})
161+
162+
for b in arm.data.bones:
163+
bname = b.name
164+
165+
# ---------- TRANSLATE (x,y) from PoseBone.location X/Y (in px) ----------
166+
loc_path = _bone_path(bname, "location")
167+
loc_fc = _fcurves_for_path(action, loc_path, index_whitelist=[0, 1, 2])
168+
ix = AXIS_MAP["loc_to_spine"]["x_index"] # 0
169+
iy = AXIS_MAP["loc_to_spine"]["y_index"] # 1
170+
# map by index, not order
171+
by_idx = {fc.array_index: fc for fc in loc_fc}
172+
pair = []
173+
if ix in by_idx: pair.append(by_idx[ix])
174+
if iy in by_idx: pair.append(by_idx[iy])
175+
176+
if len(pair) == 2:
177+
vx = lambda v: round(float(v) * float(SCALE), 4)
178+
vy = lambda v: round(float(v) * float(SCALE), 4)
179+
# keep channel order [X, Y] explicitly
180+
pair.sort(key=lambda fc: 0 if fc.array_index == ix else 1)
181+
keys, _ = _emit_channel_timelines(pair, to_seconds, [vx, vy])
182+
if keys:
183+
for k in keys:
184+
x, y = k.pop("_vals")
185+
# Always write both axes so Spine shows motion even if one stays 0
186+
k["x"] = x
187+
k["y"] = y
188+
bones_block.setdefault(bname, {})["translate"] = keys
189+
190+
# ---------- ROTATE (leave unchanged) ----------
191+
rot_idx = per_bone_rot.get(bname, AXIS_MAP["rot_euler_index"])
192+
rot_path = _bone_path(bname, "rotation_euler")
193+
rot_fc = _fcurves_for_path(action, rot_path, index_whitelist=[rot_idx])
194+
if rot_fc:
195+
rxf = lambda v: round(math.degrees(float(v)), 4)
196+
keys, _ = _emit_channel_timelines(rot_fc, to_seconds, [rxf])
197+
if keys:
198+
wrote_any = False
199+
for k in keys:
200+
(angle,) = k.pop("_vals")
201+
# NOTE: keeping your field name usage as-is ("value") per your request not to touch rotation section
202+
if abs(angle) > 1e-9:
203+
k["value"] = angle
204+
wrote_any = True
205+
if wrote_any:
206+
bones_block.setdefault(bname, {})["rotate"] = keys
207+
208+
# ---------- SCALE (x,y) from PoseBone.scale X/Z (unitless) ----------
209+
scl_path = _bone_path(bname, "scale")
210+
scl_fc = _fcurves_for_path(action, scl_path, index_whitelist=[0, 1, 2])
211+
sx_i = AXIS_MAP["scale_to_spine"]["x_index"]
212+
sy_i = AXIS_MAP["scale_to_spine"]["y_index"]
213+
by_idx_s = {fc.array_index: fc for fc in scl_fc}
214+
spair = []
215+
if sx_i in by_idx_s: spair.append(by_idx_s[sx_i])
216+
if sy_i in by_idx_s: spair.append(by_idx_s[sy_i])
217+
218+
if len(spair) == 2:
219+
# keep channel order [scaleX(source index), scaleY(source index)]
220+
spair.sort(key=lambda fc: 0 if fc.array_index == sx_i else 1)
221+
idf = lambda v: round(float(v), 6)
222+
keys, _ = _emit_channel_timelines(spair, to_seconds, [idf, idf])
223+
if keys:
224+
for k in keys:
225+
sx, sy = k.pop("_vals")
226+
k["x"] = sx
227+
k["y"] = sy
228+
bones_block.setdefault(bname, {})["scale"] = keys
229+
230+
if bones_block:
231+
anim_block["bones"] = bones_block
232+
233+
anim[anim_name] = anim_block
234+
return anim
235+
236+
__all__ = ["build_animations"]

Output/SPINE_Output_config.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import math
2+
3+
SPINE_VERSION = "4.3.39-beta"
4+
5+
RIG_SCALE_MODE = "AUTO"
6+
RIG_SCALE_CONSTANT = 100.0
7+
AUTO_MIN_S = 0.01
8+
AUTO_MAX_S = 10000.0
9+
10+
ROOT_ROTATION_DEG = 90.0
11+
VERTICAL_TOL_DEG = 8.0
12+
WEIGHT_EPS = 1e-4
13+
MAX_INFLUENCES = 4
14+
15+
SLOT_BONE_POLICY = "NEUTRAL_ROOT"
16+
17+
COPY_IF_MISSING = True
18+
PRESERVE_PARENT_DIRS = 2
19+
IMG_EXTS = {".png",".jpg",".jpeg",".tga",".bmp",".psd",".tif",".tiff",".webp"}
20+
21+
MESH_ROTATE_DEG_DEFAULT = -90.0
22+
MESH_ROTATE_DEG_BY_OBJECT = {
23+
# "Torso_Shirt": None,
24+
}
25+
26+
USE_EMPTY_AS_ROOT_FRAME = True
27+
ROOT_EMPTY_NAME = None
28+
29+
EMIT_NONESSENTIAL = True
30+
EDGES_MODE = "safe"
31+
32+
__all__ = [
33+
"SPINE_VERSION","RIG_SCALE_MODE","RIG_SCALE_CONSTANT","AUTO_MIN_S","AUTO_MAX_S",
34+
"ROOT_ROTATION_DEG","VERTICAL_TOL_DEG","WEIGHT_EPS","MAX_INFLUENCES",
35+
"SLOT_BONE_POLICY","COPY_IF_MISSING","PRESERVE_PARENT_DIRS","IMG_EXTS",
36+
"MESH_ROTATE_DEG_DEFAULT","MESH_ROTATE_DEG_BY_OBJECT","USE_EMPTY_AS_ROOT_FRAME",
37+
"ROOT_EMPTY_NAME","EMIT_NONESSENTIAL","EDGES_MODE"
38+
]

0 commit comments

Comments
 (0)