Scene Saver: STL

From The Foundry MODO SDK wiki
Jump to: navigation, search

Introduction

A walkthrough of the implementation of the STL scene saver plugin that ships with modo 701. Features covered include:

  • Using a TriangleSoup object to parse the mesh surfaces and enumerate the polgons & points
  • Scene Saver Server class
  • Use of Persistent Data and Visitor classes to store and retrieve data from the user config file.
  • Custom Command plugin and associated configuration files to expose the persistent data as settings in modo's preferences dialog



The Scene Saver and Custom Command Plugin Code

#!/usr/bin/env python
 
# STL saver plug-in, in Python
import StringIO
from struct import Struct, pack
import lx
import lxifc
import lxu.vector
import lxu.command
 
 
 
#===============================================================================
# "units" is a list of the default units used by the target application to
# interpret the values stored in the STL file and which will be used by the
# export settings custom command defined later to populate the "units" pop-up
# list choice.
#
# The list contains three tuples, the internal names used by the pop-up class,
# the "Usernames" used by the pop-up class and a "scale factor" per unit that
# will be used scale the vertex values output to the STL file in order to match
# the units used by the destination application to interpret the STL file.
#===============================================================================
 
units = [('mm', 'cm', 'm', 'in',),
         ('Milimeters', 'Centimeters', 'Meters', 'Inches',),
         (1000, 100, 1, 39.3701,)]
 
 
 
#===============================================================================
# The TriSoup (TriangleSoup) class gets called by the "Saver" class with the
# contents of a surface. We collect vertices for each segment and dump each
# polygon as a triangle.
#===============================================================================
 
class TriSoup(lxifc.TriangleSoup):
    def __init__(self):
        self.fmtASCII = False
        self.polycount = 0
        # output will eventually hold either a list of lines to write to the output
        # file (ASCII format) or a single string of packed binary values
        # (binary format). The exact type (list or string) is set by the saver
        # when it creates the triangle soup object.
        self.output = None
        # if we're outputting binary 's' will point to the pre-initialized 'Struct'
        # object we're using to pack the binary data - set by the saver class when
        # it creates the TriSoup object.
        self.s = None
 
        # scale factor for the destination/target application. Also set by the
        # saver class
        self.factor = None
        self.vdesc = lx.service.Tableau().AllocVertex()
        self.vdesc.AddFeature(lx.symbol.iTBLX_BASEFEATURE, lx.symbol.sTBLX_FEATURE_POS)
 
    def Sample(self, surf):
        surf.SetVertex(self.vdesc)
        surf.Sample(surf.Bound(), -1.0, self)
 
    def soup_Segment(self, segID, stype):
        # clear the verts list and return a new triangle segment.
        self.vrts = []
        return stype == lx.symbol.iTBLX_SEG_TRIANGLE
 
    def soup_Vertex(self, vbuf):
        # Gets the next vertex, scales it using the scale factor for the output
        # (preference) units and adds it to the current polygon
        vbuf.setType('f')
        vbuf.setSize(3)
        x = lxu.vector.scale(vbuf.get(), self.factor)
        x = (x[2], x[0], x[1])
        self.vrts.append(x)
        return len(self.vrts) - 1
 
    def soup_Polygon(self, v0, v1, v2):
        # process the next polygon
        x0 = lxu.vector.sub(self.vrts[v1], self.vrts[v0])
        x1 = lxu.vector.sub(self.vrts[v2], self.vrts[v0])
        norm = lxu.vector.normalize(lxu.vector.cross(x0, x1))
 
        # If the output format is ASCII add the next polygon to the list of strings
        if self.fmtASCII:
            self.output.append("facet normal {0:.8f} {1:.8f} {2:.8f}".format(norm[0], norm[1], norm[2]))
            self.output.append("  outer loop")
 
            for v in (v0, v1, v2):
                pos = self.vrts[v]
                self.output.append("    vertex {0} {1} {2}".format(pos[0], pos[1], pos[2]))
 
            self.output.append("  end loop")
            self.output.append("end facet")
 
        # otherwise we're outputting binary format so we pack the vert & normal
        # values for the next polygon and add them to the output string.
        else:
            p0x, p0y, p0z = self.vrts[v0]
            p1x, p1y, p1z = self.vrts[v1]
            p2x, p2y, p2z = self.vrts[v2]
            # we're using the StringIO module so we can write to the output
            # string as if it was a file
            self.output.write(self.s.pack(norm[0], norm[1], norm[2], p0x, p0y, p0z, p1x, p1y, p1z, p2x, p2y, p2z))
            self.polycount += 1
 
 
 
#===============================================================================
# The main STL saver class
#
# The saving process gathers all the surface items in the scene, and scans them
# as if they were a single triangle soup. The result of the scan is either lines
# of text (for ASCII output) or a packed string of binary values (for binary
# output) which we write to the destination file at the end.
#===============================================================================
 
class STLSaver(lxifc.Saver):
    def sav_Save(self, source, filename, monitor):
        # get the output unit and format from preferences - see the
        # 'CmdSTLExportSettings' custom command further down for details.
        cmd_svc = lx.service.Command()
        cmd = cmd_svc.Spawn(lx.symbol.iCTAG_NULL, 'stl.settings')
        # Are we saving as ASCII or binary?
        va, idx = cmd_svc.QueryArgString(cmd, 0, 'stl.settings format:?', 1)
        fmtASCII = va.GetInt(0)
        # what units does the destination application use?
        va, idx = cmd_svc.QueryArgString(cmd, 0, 'stl.settings units:?', 1)
        unit = va.GetString(0)
        # get the scale factor for the destination application according to units.
        scale_factor = units[2][units[0].index(unit)]
 
        # Get the current scene
        scene = lx.object.Scene(source)
        # and a channel read object
        cread = scene.Channels(None, lx.service.Selection().GetTime())
 
        # Get list of Surface items in the scene.
        silst = []
        isurf = lx.object.SurfaceItem()
 
        for i in range(scene.ItemCount(-1)):
            item = scene.ItemByIndex(-1, i)
            if isurf.set(item):
                silst.append(isurf.GetSurface(cread, 1))
 
        # create & initialise the TriangleSoup object
        soup = TriSoup()
        soup.fmtASCII = fmtASCII
        soup.factor = scale_factor
 
        # If the ouput format is ASCII the triangle soup object needs a list to
        # populate with lines for the ouput file, otherwise it needs an empty
        # string and we'll use the StringIO module instead of a raw string so
        # that we can write to it like a file (improves performance)
        if fmtASCII:
            soup.output = []
        else:
            soup.output = StringIO.StringIO()
            soup.s = Struct('12fxx')
        samp = lx.object.TableauSurface()
 
        # parse each surface item in turn & build a list of triangles to output.
        for si in silst:
            for i in range(si.BinCount()):
                samp.set(si.BinByIndex(i))
                soup.Sample(samp)
 
        # If the ouput format is ASCII write the file to disk usng the triangle
        # soup output list
        if fmtASCII:
            try:
                fout = open(filename, 'w')
            except:
                lx.throw(lx.result.FAILED)
            else:
                fout.write('solid\n')
                fout.writelines( [x + '\n' for x in soup.output] )
                fout.write('end solid\n')
                fout.close()
 
        # otherwise output is binary and we just need to pack the required header
        # and poly count value then dump that followed by the triangle soup's
        # 'output' string.
        else:
            try:
                fout = open(filename, 'wb')
            except:
                lx.throw(lx.result.FAILED)
            else:
                fout.write(pack('80sl', 'Output by Luxology\'s modo', soup.polycount))
                fout.write(soup.output.getvalue())
                fout.close()
 
# Set the server's tags
tags = {
    # The "UserName" is the string that appears in the file save dialog's pop-up
    # list of savers.
    lx.symbol.sSRV_USERNAME: "Stereolithograhpy STL",
    # declares the plugin as a "Saver" of type "SCENE"
    lx.symbol.sSAV_OUTCLASS:  lx.symbol.a_SCENE,
    # sets the DOS extension.
    lx.symbol.sSAV_DOSTYPE : "STL"
}
# bless the saver to register it as a first class server (plugin)
lx.bless(STLSaver, "pySTLScene2", tags)
 
 
 
 
#===============================================================================
# Custom command to set STL export prefs. Implements persistent storage to store
# the preference settings in the user config file.
#===============================================================================
 
 
#===============================================================================
# UIValueHints class defining a pop-up choice for the target application's
# default units.
#===============================================================================
 
class UnitsPopup(lxifc.UIValueHints):
    def __init__(self, items):
        self._items = items
 
    def uiv_Flags(self):
        # This can be a series of flags, but in this case we're only returning
        # 'fVALHINT_POPUPS' to indicate that we just need a straight pop-up
        # List implemented.
        return lx.symbol.fVALHINT_POPUPS
 
    def uiv_PopCount(self):
        # returns the number of items in the list/pop-up
        return len(self._items[0])
 
    def uiv_PopUserName(self,index):
        # returns the Username of the item at 'index'
        return self._items[1][index]
 
    def uiv_PopInternalName(self,index):
        # returns the internal name of the item at 'index' - this will be the
        # value returned when the custom command is queried
        return self._items[0][index]
 
 
 
#===============================================================================
# Persistent data class - persistent date enables storing and retrieval of
# attribute values (or any other data) as entries in the user config file.
#
# NOTE: usually you'd probably only define a complete class for
# the persistent data if you needed to store a reasonably complex or varied
# collection of attribute values. For an example of a much simpler implementation
# see example 1 at: http://modo.sdk.thefoundry.co.uk/wiki/Persistent_Data
#
# In this particular example we have just two values that we need to store, a
# string value that specifies the units the destination application will use
# to interpret the (dimensionless) values stored in the output file and a boolean
# value that specifies whether the output format is ASCII or binary.
#===============================================================================
 
class STLPersistData(object):
    def __init__(self):
        # 'accesor' object for the 'units' atom
        self.units = None
        # 'accesor' object for the 'format' atom
        self.saveASCII = None
 
        # the "units" atom's actual value - set to "cm" by default. This is
        # actually an "attribute" object connected to the "units" accessor
        # defined above.
        self.units_val = 'cm'
        # the "format" atom's actual value - set to "False" by default
        self.saveASCII_val = False
 
    def get_units(self):
        # returns the value of the 'units' atom or a default value of 'cm'
        # if the read fails (eg if 'units_val' is unset for any reason)
        try:
            return self.units_val.GetString(0)
        except:
            return 'cm'
 
    def get_format(self):
        # returns the value of the 'format' atom or a default value of 'False' (0)
        # if the read fails (eg if 'saveASCII_val' is unset for any reason)
        try:
            return self.saveASCII_val.GetInt(0)
        except:
            return 0
 
    def set_units(self, units):
        # appends a 'units' atom and writes the current value of the 'units_val'
        # attribute
        self.units.Append()
        self.units_val.SetString(0, units)
 
    def set_format(self, saveASCII):
        # appends a 'format' atom and writes the current value of the 'saveASCII_val'
        # attribute
        self.saveASCII.Append()
        self.saveASCII_val.SetInt(0, saveASCII)
 
 
 
#===============================================================================
# We need to declare/store a global reference to the persistent data object so
# that it can be accessed throughout an entire session. Here we just set it to
# "None", it will be assigned/configured as an actual persistent data object
# when the export settings custom command is first blessed (registered as a
# plugin server)
#===============================================================================
persist_data = None
 
 
 
#===============================================================================
# This is the persistent data visitor class. It's is responsible for walking
# the XML structure inside the top level atom - the top level (outer countainer)
# atom, in this example the 'STLSaverSettings' atom, is created when our '
# PersistData' instance is configured (see 'persist_setup()' function below).
# This will result in a config entry like the following if the settings are
# changed from their defaults.
#
# <atom type="STLSaverSettings">
#    <atom type="units">mm</atom>
#    <atom type="format">1</atom>
# </atom>
#===============================================================================
 
class VisSTLSettings(lxifc.Visitor):
    def vis_Evaluate(self):
        # grab a reference to the session-wide global persistent data instance
        global persist_data
        persist_svc = lx.service.Persistence()
 
        # create 'units' atom
        persist_svc.Start("units", lx.symbol.i_PERSIST_ATOM)
        # add a string value
        persist_svc.AddValue(lx.symbol.sTYPE_STRING)
        # closing the atom returns the 'accessor' object which we assign
        # to the variable set up in our persistent data object.
        persist_data.units = persist_svc.End()
        # attach the persistent data object's "units_val" variable as an
        # attribute on the "units" accessor object.
        persist_data.units_val = lx.object.Attributes(persist_data.units)
 
        # create & set the 'format' atom
        persist_svc.Start("format", lx.symbol.i_PERSIST_ATOM)
        persist_svc.AddValue(lx.symbol.sTYPE_BOOLEAN)
        persist_data.saveASCII = persist_svc.End()
        persist_data.saveASCII_val = lx.object.Attributes(persist_data.saveASCII)
 
        return lx.symbol.e_OK
 
 
 
#===============================================================================
# persistent data setup function. Called by the custom command below to
# configure the persistent data object.
#
# IMPORTANT NOTE: configuration should only be performed ONCE per session, so
# the setup function should first check to determine whether the persist_data
# object exists before continuing.
#===============================================================================
 
def persist_setup():
    # grab a reference to the session-wide persistent data instance
    global persist_data
    # IMPORTANT: check to see if it's not None, return if it isn't
    # as we only want to configure it once per session!!!
    if persist_data:
        return
    # create our persitent data object
    persist_data = STLPersistData()
    persist_svc = lx.service.Persistence()
    # and our persistent data visitor
    persist_vis = VisSTLSettings()
    # configure the persistent data visitor - initialises the outer
    # atom of the config entry.
    persist_svc.Configure('STLSaverSettings', persist_vis)
 
 
 
#===============================================================================
# And, finally, the custom command that we're going to use to to read and write
# the persistent data. The command can be embedded in a form, in this case a
# form in modo's preferences dialog, enabling users to set the default output
# properties of the saver (format and units) and queried by the 'Saver' class to
# retrieve the currently set preferences.
#===============================================================================
 
class CmdSTLExportSettings(lxu.command.BasicCommand):
    # grab a reference to the session-wide persistent data instance
    global persist_data
    def __init__(self):
        lxu.command.BasicCommand.__init__(self)
        persist_setup()
 
        # Add a string attribute for the "units" value
        self.dyna_Add('units', lx.symbol.sTYPE_STRING)
        # Add a boolean atttribute to store the format (ASCII or binary)
        self.dyna_Add('format', lx.symbol.sTYPE_BOOLEAN)
 
        # set the flags for the attributes - both are queriable and both are optional
        # ie they can be set and queried individually.
        self.basic_SetFlags(0, lx.symbol.fCMDARG_QUERY | lx.symbol.fCMDARG_OPTIONAL)
        self.basic_SetFlags(1, lx.symbol.fCMDARG_QUERY | lx.symbol.fCMDARG_OPTIONAL)
 
 
    def arg_UIHints(self, index, hints):
        # set the hints for the attributes' labels - the label that will appear
        # next to the control on the form the command is embedded in.
        if index == 0:
            hints.Label("Units")
        if index == 1:
            hints.Label("ASCII Output")
 
 
    def arg_UIValueHints(self, index):
        # create an instance of our pop-up list object passing it the
        # list of units (see list defined at top of file).
        if index == 0:
            return UnitsPopup(units)
 
 
    def basic_Execute(self, msg, flags):
        # execute is fired when the value of either of the attributes changes in
        # the UI. We simply set the relevent attribute on the persistent data object.
        if self.dyna_IsSet(0):
            persist_data.set_units(self.dyna_String(0))
        if self.dyna_IsSet(1):
            persist_data.set_format(self.dyna_Bool(1))
 
 
    def cmd_Query(self,index,vaQuery):
        # query method reads the current value of the requested attribute from the
        # persistent data object.
        va = lx.object.ValueArray()
        va.set(vaQuery)
        if index == 0:
            va.AddString(persist_data.get_units())
        elif index == 1:
            va.AddInt(persist_data.get_format())
        return lx.result.OK
# bless the command to register it as a first class server (plugin)
lx.bless(CmdSTLExportSettings, "stl.settings")



Building the Configuration Entries

Adding to modo's Preferences dialog

Creating a form

We'll need a form to embed in modo's preferences dialog. It'll contain just two entries to query each of the STL saver's options defined by the stl.settings custom command. Two important items to note in the form definition are the InCategory hash which adds our form to preferences and the filter atom which allows the preferences form to dynamically place it in the correct section.

  <atom type="Attributes">
    <hash type="Sheet" key="05044994227:sheet">
      <atom type="Label">STL Object Export</atom>
      <list type="Control" val="cmd stl.settings units:?">
        <atom type="Label">Interpret units as</atom>
        <atom type="Tooltip">Set the units that the destination software will use to interpret the values in the STL file</atom>
      </list>
      <list type="Control" val="cmd stl.settings format:?">
        <atom type="Label">Save as ASCII</atom>
        <atom type="Tooltip">Save the STL file as ASCII, default is binary output.</atom>
      </list>
      <atom type="Filter">prefs/fileio/stlio:filterPreset</atom>
      <hash type="InCategory" key="prefs:general#head">
        <atom type="Ordinal">80.8</atom>
      </hash>
      <atom type="Group">prefs/fileio</atom>
    </hash>
  </atom>


Adding a Filter Preset

Define the filter preset referenced by our form and which specifies where it should appear.

  <atom type="Filters">
    <hash type="Preset" key="prefs/fileio/stlio:filterPreset">
      <atom type="Name">STL I/O</atom>
      <!-- 20385740002:filterCat is the "Preferences" category -->
      <atom type="Category">20385740002:filterCat</atom>
      <atom type="Enable">1</atom>
      <list type="Node">1 .group 0 &quot;&quot;</list>
      <list type="Node">1 prefType fileio/stlio</list>
      <list type="Node">-1 .endgroup </list>
    </hash>
  </atom>


Adding a Preferences category

We'll add a new preferences category - actually a new subcategory of the existing filio category. This enables our form to appear as a "child" of the filio section of the preferences dialog.

  <atom type="PreferenceCategories">
    <hash type="PrefCat" key="fileio/stlio"></hash>
  </atom>


Adding a Message Table entry

We'll also need a message table entry to define the name/label for our new category.

  <atom type="Messages">
    <hash type="Table" key="preferences.categories.en_US">
      <hash type="T" key="fileio/stlio">STL I/O</hash>
    </hash>
  </atom>


Adding a CommandHelp entry

Finally we'll add a CommandHelp entry for the stl.settings custom command, providing meaningful tooltips and description for both the command and it's arguments/attributes in addition to enabling language translation support.

  <atom type="CommandHelp">
    <hash type="Command" key="stl.settings@en_US">
      <atom type="UserName">STL Saver settings</atom>
      <atom type="ButtonName">STL Prefs</atom>
      <atom type="Desc">Command to set the output prefernces of the STL saver</atom>
      <atom type="ToolTip">Set the STL saver preferences</atom>
      <hash type="Argument" key="units">
        <atom type="UserName">Units</atom>
        <atom type="Desc">Selects which units the destination application will use.</atom>
        <atom type="ToolTip">Selects which units the destination application will use to interpret the values in the STL file.</atom>
      </hash>
      <hash type="Argument" key="format">
        <atom type="UserName">Save ASCII</atom>
        <atom type="Desc">Save the file in ASCII format, default is binary.</atom>
        <atom type="ToolTip">Save the file in ASCII format, default is binary output.</atom>
      </hash>
    </hash>
  </atom>


The Complete Config File

Putting it all together we now have a config file that adds the output settings for our STL saver to modo's preferences and provides some meaningful UI feedback about the custom command that implements them.

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <atom type="Attributes">
    <hash type="Sheet" key="05044994227:sheet">
      <atom type="Label">STL Object Export</atom>
      <list type="Control" val="cmd stl.settings units:?">
        <atom type="Label">Interpret units as</atom>
        <atom type="Tooltip">Set the units that the destination software will use to interpret the values in the STL file</atom>
      </list>
      <list type="Control" val="cmd stl.settings format:?">
        <atom type="Label">Save as ASCII</atom>
        <atom type="Tooltip">Save the STL file as ASCII, default is binary output.</atom>
      </list>
      <atom type="Filter">prefs/fileio/stlio:filterPreset</atom>
      <hash type="InCategory" key="prefs:general#head">
        <atom type="Ordinal">80.8</atom>
      </hash>
      <atom type="Group">prefs/fileio</atom>
    </hash>
  </atom>
 
  <atom type="Filters">
    <hash type="Preset" key="prefs/fileio/stlio:filterPreset">
      <atom type="Name">STL I/O</atom>
      <!-- 20385740002:filterCat is the "Preferences" category -->
      <atom type="Category">20385740002:filterCat</atom>
      <atom type="Enable">1</atom>
      <list type="Node">1 .group 0 &quot;&quot;</list>
      <list type="Node">1 prefType fileio/stlio</list>
      <list type="Node">-1 .endgroup </list>
    </hash>
  </atom>
 
  <atom type="PreferenceCategories">
    <hash type="PrefCat" key="fileio/stlio"></hash>
  </atom>
 
  <atom type="Messages">
    <hash type="Table" key="preferences.categories.en_US">
      <hash type="T" key="fileio/stlio">STL I/O</hash>
    </hash>
  </atom>
 
 
  <atom type="CommandHelp">
    <hash type="Command" key="stl.settings@en_US">
      <atom type="UserName">STL Saver settings</atom>
      <atom type="ButtonName">STL Prefs</atom>
      <atom type="Desc">Command to set the output prefernces of the STL saver</atom>
      <atom type="ToolTip">Set the STL saver preferences</atom>
      <hash type="Argument" key="units">
        <atom type="UserName">Units</atom>
        <atom type="Desc">Selects which units the destination application will use.</atom>
        <atom type="ToolTip">Selects which units the destination application will use to interpret the values in the STL file.</atom>
      </hash>
      <hash type="Argument" key="format">
        <atom type="UserName">Save ASCII</atom>
        <atom type="Desc">Save the file in ASCII format, default is binary.</atom>
        <atom type="ToolTip">Save the file in ASCII format, default is binary output.</atom>
      </hash>
    </hash>
  </atom>
 
</configuration>



See Also