Sunday, December 6, 2009

MaxScript DotNet Sockets with Python

I create and work with several Python tools that manipulate scenes in 3ds Max. These are usually floating dialogs linked to the main 3ds Max window that send MaxScript commands via COM. This works well until I need the tool's UI to update when something happens in 3ds Max. Like refresh an object list when the selection in Max changes.

You would think doing a COM connection the other way would work. However, since Python-registered COM servers run separately in their own instance of the interpreter, there's no native connection to the original tool.

Nathaniel Albright, a fellow TA at Volition, recently created a Python COM server that communicated to his Python tool via TCP/IP socket. So it went 3ds Max -> Python COM server -> TCP/IP -> Python tool. This works well, but I wondered if the DotNet facilities in MaxScript offered a direct way to use sockets.

I had yet to touch DotNet in MaxScript, so this seemed like a good opportunity to learn a few things. After a lot of searching online I only turned up a few scraps of info, no complete recipe. However, I did find enough to get MXS DotNet sockets working, and assemble a comprehensive example.

I created a little Python tool that displays the names of all selected objects in the 3ds Max scene. As the scene selection changes, the list of names automatically updates. I won't go over all the code in this post, but the full working example tool is included in the zipfile below.

There's three main points of interest in the example:

1. Using DotNet in MaxScript to communicate via TCP/IP socket
2. Listening on a socket in a background thread in Python
3. Creating and posting custom wxPython events

The MaxScript Client

I made a MaxScript struct called "mxs_socket". The code follows, and also included in the zipfile below.

struct mxs_socket (
   ip_address = "127.0.0.1", -- "localhost" also valid
   port       = 2323,        -- default port

   -- <dotnet>connect <string>ip_string <int>port_int
   --
   -- Description:
   -- Takes IP address, port and connects to socket listener at that
   -- address
   fn connect ip_string port_int = (
      socket = dotNetObject "System.Net.Sockets.Socket" ( dotnetclass "System.Net.Sockets.AddressFamily" ).InterNetwork ( dotnetclass "System.Net.Sockets.SocketType" ).Stream ( dotnetclass "System.Net.Sockets.ProtocolType" ).Tcp
      socket.Connect ip_string port_int

      socket   -- return
   ),

   -- <int>send <string>data
   --
   -- Description:
   -- Converts a string (or any object that can be converted
   -- to a string) to dotnet ASCII-encoded byte sequence and
   -- sends it via socket. Uses ip_address and port defined
   -- in struct above, or set by client.
   -- Returns integer of how many bytes were sent.
   fn send data = (
      -- Convert string to bytes
      ascii_encoder = dotNetObject "System.Text.ASCIIEncoding"
      bytes = ascii_encoder.GetBytes ( data as string )

      -- Connect, send bytes, then close
      socket = connect ip_address port
      -- result is # of bytes sent
      result = socket.Send bytes
      socket.Close()

      result  -- return # of bytes sent
   )
)
Using this, I can send bytes to any socket listener on port 5432 by doing the following:
socket = mxs_socket port:5432
socket.send "Hello, World!"
The connect method was pretty simple in the end. The only twist turned out to be converting the socket integer into a DotNet socket object.

The send method converts the string into an ASCII-encoded DotNet bytes object, connects to the listener, sends the bytes, then closes the connection. The value returned is the number of bytes sent.

The last lines of the above code sets up a MaxScript callback that fires when the object selection changes in the scene. That uses mxs_socket to send a string containing the names of all the selected objects to any tool that's listening on that port.

Now I just need to make my Python tool listen.

The Python Server

My Python server/listener (also in the zipfile below) is a typical wxPython frame, but with two added qualities... It uses a background thread to listen on a socket, and posts a custom wx.Event when data is received. I had never used either of these techniques before, but it was fun getting it working.

Since a typical wxPython app sits in its main loop waiting for user input, I created a Socket_Listen_Thread class, a subclass of threading.Thread. This does the listening in a background thread while the main UI thread waits on the user. The run method here does the real work:
def run( self ):
   self.running = True

   while ( self.running ):
      # Starting server...
      # Listen for connection.  We're in non-blocking mode so it can
      # check for the signal to shut down from the main thread.
      try:
         client_socket, clientaddr = self.socket.accept( )
         data_received = True
      except socket.error:
         data_received = False

      if ( data_received ):
         # Set new client socket to block.  Otherwise it will
         # inherit the non-blocking mode of the server socket.
         client_socket.setblocking( True )

         # Connection found, read its data then close
         data = client_socket.recv( self.buffer_size )
         client_socket.close( )

         # Create wx event and post it to our app window
         event = self.event_class( data = data )
         wx.PostEvent( self.window, event )
This listening thread runs quietly in the background until it receives data. At that point I need a way to break into the main UI thread. There's other ways to do this, but using a custom wx.Event seemed to be the best fit here.

First, when the wx.Frame is opened, I create a custom wxPython event.
(Max_Update_Event, EVT_3DS_MAX_UPDATE) = wx.lib.newevent.NewEvent()
Calling NewEvent returns both a new Event class and an object to bind the event handler to. I pass the event class to the listener thread, bind an event handler to it, and that's all.

When data comes in over that TCP/IP port from our MaxScript tool, the listening thread receives it and posts our custom event to the main wx.Frame. That in turn fires the event handler to update the UI.

My example MaxScript client and Python listener described above can be found in the following ZIP file. Drop me a line if you do something useful with them, I'd love to hear about it.

MaxScript_DotNet_Sockets_Python.zip (4 KB)

Thanks to Nate Albright and everyone contributing to the "dotNet + MXS" and "Python + MXS" threads on the CGSociety forums. Those long-running threads have been very inspiring, and contain several tips that were key in getting the MaxScript DotNet socket stuff hammered out.

11 comments:

Erik Larsson said...

Hey Adam!
Tistatos from techart here.
thanks for the awesome blog, help me alot with my first python/photoshop experience.

i've been trying to do the same thing you've done here with maya and just want to make sure i got things right.

Instead of letting Python connect to Max you do the opposite?

Adam Pletcher said...

I have many Python tools that control Max over COM. Examples of that can be seen in my GDC talk samples:
http://www.volition-inc.com/gdc

I've also made tools that go from Max-to-Python over COM, as described here:
http://techarttiki.blogspot.com/2008/03/calling-python-from-maxscript.html

This particular post covered how to use TCP/IP to do the Max-to-Python connection instead of COM. Your tool can do that only, or do Python-to-Max over COM at the same time.

This was valuable to me because doing it both directions in one tool over COM ends up being quite complex, due to how Python's COM registration works.

Make sense?

Unknown said...

Hi,

would it be possible to make Max as the server instead ?
I'm trying but cannot find a way :(

Rodrigo Pegorari said...
This comment has been removed by the author.
Rodrigo Pegorari said...

Hello Adam,

based on your code, I made a small tool to help Processing users to connect with 3dsMax.

Thanks man!

Check at
http://rodrigopegorari.net/blog/?p=30

Adam Pletcher said...

Very cool, Rodrigo, glad it worked out for you.

@francois:
I imagine there's an equivalent .NET control for listening on a socket. I haven't tried this out, but you might have luck using "System.Net.Sockets.TcpListener".

Unknown said...

Hi Adam!
im trying to implement your wonderful techniques and have some problem in the early beginning :)

[code]
socket = dotNetObject "System.Net.Sockets.Socket" ( dotnetclass "System.Net.Sockets.AddressFamily" ).InterNetwork ( dotnetclass "System.Net.Sockets.SocketType" ).Stream ( dotnetclass "System.Net.Sockets.ProtocolType" ).Tcp
socket.Connect ip_string port_int
[/code]

gives me:
-- Runtime error: dotNet runtime exception: No connection could be made because the target machine actively refused it 127.0.0.1:2323

tried other ports, just the same.
Don't you know what can it be? =)

Unknown said...

oh, sorry, Adam, i've got it - my question was very stupid, i ran maxscript before starting server.. sorry once more and thank you very much for this example!

Mattias said...

Hey Adam,

Just wanted to let you know that this code really helped me in getting my MaxScript command line tool up and running - I was looking for a way to get an efficient logging system running, and file saving/loading just wasnt fast enough... this dotnet socket interface was finally what i needed, so thanks for putting this out there - it really helped.

Adam Pletcher said...

Great, glad it helped! I was just looking at this post yesterday, wondering if it still worked in recent 3ds Max releases. I'm not in max as much as I used to be...

Just Info said...

Great, I think this is one of the best blog in past some time I have seen. Visit Kalakutir for Fleet Painting, Godown Line Marking Painting and Caution & Indication Signages.
Godown Line Marking Painting