RSS FEED

3ds Max SDK: First Steps for Scripters

Even for seasoned scripters, the idea of dipping their toes into the C++/SDK territory often looks daunting, even more so when confronted with the sheer volume of the documentation and all the topics covered. That said, there's no need to go full C++ right away. I'll cover the process of creating your own MAXScript functions using the Function Publishing system, and how to leverage them in a pair of sample scripted simpleMeshMods.

I'm gonna briefly touch a lot of topics, and many tangentially related concepts and features will be referenced. It's not important that everything makes sense on the first read, maybe not on the tenth either but I hope you'll stick around long enough that everything eventually falls into place and when it does, the code you write will be more robust.

1. Visual Studio
2. Max SDK
3. Max Plugin Wizard
4. New Project
5. First Build
6. Core Interface
7. Core Interface Instance
8. Interface Methods
9. Static Interface
10. Interface Methods Continued
11. THE Core Interface
12. Convenience Features

Visual Studio

First of all, let's download and install Visual Studio – I'll be using 2019 Community (download link) in the course of this article, and I suggest you do too. It is binary compatible with VS 2017 and a bit more friendly to C++ devs. That said, every now and then I'll mention alternative ways how to do certain things if you're still using VS 2017.

During the Visual Studio installation process, you will have to make some changes to the individual components installed – though the Desktop Development with C++ is a good start. At the very least, you need to check the right Win 10 SDK and Platform Toolset versions – refer to the SDK Requirements page for the intended max version. For example when developing plugins for max 2018/19/20, you'll want to check both Windows 10 SDK (10.0.10586) and (10.0.17134.0) and VC++ platform toolsets v140 and v141. You have this option right in the installer in VS 2017; in VS 2019 a separate Windows 10 SDK download is needed for the older version. You can get the required version from the SDK archive

I also tend to remove some stuff like Visual C++ ATL (there are still some MFC dependencies in the wizard but we'll get those sorted later on) and add Git if not installed – and looking at the long list, you might hesitate whether or not to add some other component – and you know what? You don't have to care about that for now as you can always come back to the installer and add or remove any of them later on.

And if you're not sure which C++ features you can use in the course of writing plugins, Max 2020 projects default to C++14, same as Visual Studio 2017. Technically, you may try and use C++17 as/std:c++17 is supposed to be binary compatible with /std:c++14 (and it seems to work without hitches) but you obviously better have a good reason for going that route in the first place.

So, back to the topic at hand, let's say we have these items checked and installation completed. Now we'll see if building anything with that will be successful.

For 2017, the component list might look like this instead:


Max SDK

The SDK installer comes bundled with the 3ds Max setup. You need to install SDK for each max version that you want to build your tools for. It's your choice whether you want to proceed with the installer via the Instal Tools and Utilities submenu of the main Max installer or by directly running the msi from inside the x64\Tools\MAXSDK folder of the installer source files.

In the course of the installation, I recommend you change the destination folder. The default location points into Program Files, and in the next steps you will need to edit a few files inside there for which you'd have to take the ownership if unpacked into a protected folder. It's more of a nuisance than a dealbreaker – still it's easier if you choose a location for which you already have the necessary permissions.


New Project Wizard

Now that the SDK files are all accessible and editable, let's add the 3ds Max Plugin wizard to Visual Studio. For the changes that need to be made to the template, your safest bet is to refer to the readme.txt file inside the maxsdk\howto\3dsmaxPluginWizard folder. It gives instructions both for VS 2015 and 2017; the only thing different for 2019 is you need to bump up the version to VsWizardEngine.16.0.

On a fresh VS install, you might need to create the vcprojects folder to copy the edited files to, too; alternatively, you can use the %USERPROFILE%\Documents\<Visual Studio version>\Wizards folder – again, the folder will have to be created if it doesn't exist. After doing that and relaunching Visual Studio, a new matching item should be available when you try to create a new project.

In VS 2019 you'd have to turn off filtering by language if active to see it, it also most likely won't be on the top of the list so scroll down and look for 3ds Max icon. In VS 2017 you input the project name and optionally uncheck the Create directory for this solution box (I prefer keeping the project self-contained) right when you pick the wizard. If you forget to check Add to Source Control here and want to use it, you can do so in a few clicks in the future (File > Add to Source Control):

In vs 2019 a separate configuration windows pops up and the logic for the checkbox is reversed (I'm checking it, I have no plans of adding other projects under the solution anyway). Bear in mind that the project name you enter will also be used as the class name by the wizard.

Confirm and in the first plugin wizard page choose Global Utility Plug-Ins as the Plugin Type.

The details on the next page don't matter that much here – if you query the utility plugin .category in max, that's what it would return, and the description will show up when you run Plug-in Manager.

At the last page, leave all the boxes unchecked and fill in the path using environment variables (they should be created by the Max and SDK installers):

MAXSDK path: $(ADSK_3DSMAX_SDK_2020)
ouput path:  $(ADSK_3DSMAX_x64_2020)\Plugins
3dsmax.exe:  $(ADSK_3DSMAX_x64_2020)

The skeleton project should be set up for you now. If you want to know more about the details of the setup – or if you need something more complex in the future and the wizard will no longer cut it – there's a Manually Creating a New Plug-in Project section of the 3ds Max developer reference. With the project created, the workspace will look probably something like this:


Since it's the only project in the solution, you can right-click the project name and choose Scope to This (you can always click the now-highlighted back arrow at the top left to get back, and it gets reset when you restart VS). Aside from that, the first thing you should do now is to switch to the Hybrid config:

You might be used to running in Debug while developing but when you start out, you will be building against the release version of 3ds Max which is what the Hybrid config is for. It's worth mentioning here that to make the experience as nice as possible, Autodesk provides public debugging symbols for 3ds Max – not something you'd need right now but it's worth keeping in mind for future reference. For the detailed walkthrough, refer to the 3ds Max Public Debug Symbols article.

And in case you're wondering why is the Debug config even there, it's because when tracking some obscure bugs, you want to get as much information and possible, and for that a debug version of 3ds Max is handy. Those are available for development and testing purposes through the Autodesk Developer Network, and there are several ways to get the ADN Standard Membership. For one, as an 'underfunded start-up developer' to quote the website, you can apply for a free ADN Standard membership. You can also get a one-year membership as a part of the AUGI (Autodesk User Group International) Professional Membership for individuals (see the terms for the ADN offer), it also comes as a benefit of the Autodesk Student Expert Membership, and there's special pricing available on demand for authors of learning materials (books/videos/online).


New Project

I tend to leave most of the autogenerated files as is, only delete the TODO #pragma messages from DllEntry.cpp (we need pretty much just empty gup plugin). Since I won't be providing any localization for the function printout either, I also remove the MaxSDK::Util::UseLanguagePackLocale(); call in the same file.

There's another TODO item in the same file, this time in the form of comment, //TODO: Must change this number when adding a new class. For our use case, this will always be one. While you can have multiple plugins – even of different types – inside one file, it's not what you should do anyway.

The other cpp file is named the same as your project. As before, you can get rid of the TODO #pragma messages here as well. The wizard generated unique Class_ID for your plugin, but I kind of don't like the comment in the code // Slapped together random num gen, and always generate a new one myself (either within max via genClassID() or using the gencid.exe inside the maxsdk\help directory). This is up to you, I like to err on the side of caution.

What indeed does matter though is that on the line where it says // Returns fixed parsable name (scripter-visible name) it applies to the plugin name as seen by MAXScript – or rather, its MAXScript alias. Chances are you named your project the way you want to access the published function interface (MyFunctions, for example, provided that it's an identifier that doesn't already exist and is not likely to cause conflict with other devs' naming). Now, this may or may not be what you'll want, depending on whether you'll want to have a single interface with all the functions (the aforementioned MyFunctions) for which Core Interface might be a better choice, or multiple interfaces grouped under one name to differentiate different methods (like MyFunctions.MeshTools, MyFunctions.SplineTools, MyFunctions.Math).

If the latter, all is right as is and you can skim through the Core Interface chapter and focus on the Static Interface one; if the former, you don't really want the property-less utility to block the interface of the same name. In that case, I tend to append "Util" to the name (it's a global utility plugin after all), here that'd make it _T("MXSExposureUtil"). There's one other place where you also have to edit the string, but before we get to that, there's something else that might have caught your attention as you edited the first one. In VS 2019, once you start making the edits, lots of squiggly lines will appear (courtesy of Code Analysis running in the background):

They mark the warnings emitted by checking the code against the rules used by Code Analysis (by default its the C++ Core Guidelines) and work as hints that you can hover over (as you can see from the tooltip) and sometimes offer matching FixIts. While certainly helpful while writing new code, I don't think anyone is terribly keen to see warnings for code that's out of their control. Especially in the Error List window (can be brought up through View > Error List or by clicking the stop sign/exclamation mark icon in VS 2019) they can actually drown the warnings that apply to your project (you can filter by current document but sometimes you really want to see a complete list of potential issues in your project without flipping through each and every file):

In VS 2017, you'd have to explicitly run Code Analysis to see that – and since it's a good practice to get into, I suggest you set Tools > Options > Text Editor > C/C++ > Experimental > Disable Background Code Analysis to False so that you get the updated error/warning list each time you save your files instead of only on demand. In VS 2019, by default this happens on the fly, automatically.

The next step is to filter out the SDK warnings without removing warnings for our code. Since the main include file is #included in all the autogenerated project files, we can paste these lines above the default includes to turn the warnings off for all the files using it:

#include <codeanalysis\warnings.h>
#pragma warning (disable : ALL_CODE_ANALYSIS_WARNINGS)

Saving the file should result in the warnings being removed (it can take a little while) and you should be left with a clean slate again. If that's not the case, try turning the Code Analysis off and on again (by toggling and applying Tools > Options > Text Editor > C/C++ > Advanced > Code Analysis > Disable C++ Code Analysis Experience twice):

Time to get back where we left off, with the name edits. The plugin className that will also be exposed to MAXScript and could shadow your interface name is taken from the value of IDS_CLASS_NAME, defined inside the resource file in the Resource Files folder. So let's have a look at it.

You might instinctively double-click it and expect it to open, but if you recall how I mentioned unchecking the MFC tools and you did just that before (or you went directly to the list of components and handpicked them one by one without checking this one), you will only get an error message – no dialog window for you. What dialog, you ask? Let me show you. Right-click that .rc file and choose Open With... C++ Source Editor. Yes, we're here for a purpose; yes, I do get distracted easily, why do you ask? Anyway, go ahead and search and replace afxres.h with winres.h and save and close the .rc file.

Now you should be able to double-click the .rc file, and navigating to the dialog inside the treeview that opens and double-clicking it should open in the designer.

It's useful as a starting point when you're making regular utility plugin but since all I care about now is publishing functions, I can delete that without regrets – one regret maybe, if you do that through the UI, it will insert an empty language block for your language (you can delete that when you open the file with source code editor again).

While you're inside the .rc file, if you want to start with the Core Interface (and I recommend that, or rather I recommend you try both) don't forget to replace the string matching the IDS_CLASS_NAME, too. I'll put there "MXSExposureUtil" once more (if you can think of a reason for making the className and MAXScript alias different, you're free to pick a different one):


And since we have no use for localization, let's turn off the .mui file postbuild event, too. Right-click the project name, choose Properties and navigate to Configuration Properties > Build Events > Post-Build Event:

You can make the change apply only to the current configuration (Hybrid) or to All Configurations.

First Build

There's one last thing to get off the table before building the project. The default output location for the compiled plugin is the Plugins folder. While this sounds straightforward, it may trip you up at first. Since you'll want to be able to write into that folder, you have to take ownership of it. Another option is to add an entry in plugin.ini to point to directory you have access to and change the build properties accordingly.

The modern alternative to that is using the Autodesk Application Plug-in Package (you can find the standards plugins using it inside the ApplicationPlugins folder in the 3ds Max root directory). This is something you should be aware of and make use of it once you're more comfortable with the whole development process, as soon as you want to start distributing your plugins. For now, let's keep it simple and stick with the wizard defaults.

With that part done, you should be able to Build the plugin, Build > Build Solution. If all the permission for all the folders are set right, the build should succeed and you can run max the way you're used to or from VS via Debug > Start Without Debugging.

By default, if you Start this way and the project was changed in the meantime, it will be rebuilt (that way if you want to build and run, all you need is to press Ctrl+F5) - if you don't like that, it can be changed under Tools > Options > Projects and Solutions > Build and Run, in the first dropdown (by default Always build). If you open MAXScript Listener, you should be able to query the utility category and class.


Time for the celebratory clean-slate commit! (at least in case that you heeded the version control call)

If you change anything in the project and try to rebuild now while max is running, the build will fail because the plugin is loaded in max. You can avoid having to kill it manually by adding a Pre-Build Event through Configuration Properties > Build Events:

WMIC PROCESS WHERE (Name='3dsmax.exe' and ExecutablePath LIKE '%25%252020%25%25') call TERMINATE

The %25%252020%25%25 part actually evaluates to %%2020%% and in batch mode this translates yet again to %2020% – which is what you'd use when at command prompt. It's there to kill only those 3dsmax.exe processes that have 2020 in their path (I like running multiple versions alongside); you can match simply against (Name='3dsmax.exe') if you want to.


Core Interface

The plugin apparently works, all that remains is to make it do something as well. For that let's add a new class to the project, Right-click the project name in the Solution Explorer and pick Add > Class... For the class name, I tend to prefix the plugin class name with 'I' for Interface (since it will be eventually exposed as one of the Core Interfaces). Fill in only the two fields framed by the blue rectangle, the class name of your choice and FPInterfaceDesc that the class will inherit from, leave the rest at default (or autocompleted) values.

Depending on the current VS configuration and version, this is what the class .cpp file might look like now:

#include "IMXSExposure.h"
 
IMXSExposure::IMXSExposure() { }
IMXSExposure::~IMXSExposure() { }

Or it might be an empty file with just the #include "YourClassName.h" on the first line in which case you don't have to do anything with it. If there's a constructor and a destructor of the class like here, I want you to delete everything but the first line. The only thing we care about now is that the first line #includes the matching class header file.

What the header files actually are and how you're supposed to structure your code with respect to them is a topic for another discussion (with lots of strong opinions along the way, even more so now that modules are almost there). Let's just say that usually you can think of them as sort of contract saying 'these are the prototypes of the data structures and functions I'll be using, they'll be implemented in a cpp source file that #includes this header somewhere along the way'. You can (and will) #include header files in other headers, and since this can nest pretty deep, there's a handy way of preventing duplicated #includes with a directive which Visual Studio will automatically insert in each new header file for you, #pragma once (you'll also often see guard macros in older files from before this was supported).

In case of the SDK headers found inside the maxsdk\include folder, this means you can include them in your code and work as if your project was part of the max project. There's more to it, especially when actually building the project – we'll get to that later on.

Keep in mind that while you'll find other max header files inside the maxsdk\samples folder, you should only treat them as such, as samples to further your understanding how things are done max-side. Everything outside the include and lib folders is subject to change, so if you included any of those header files, your plugin may break after a point update.

The wizard puts header files and source files in separate directories when it creates the project (and when you add a new class). You don't have to abide to that, some people prefer flat structure instead. In fact, your entire project structure could be just the DllEntry.cpp (or – by convention – main.cpp if you were building an executable), it just wouldn't be all that practical.

The contents of the initial header file might look something like this:

#pragma once
#include "C:\maxsdk20\include\ifnpub.h"
 
class IMXSExposure :
    public FPInterfaceDesc
{
public:
    IMXSExposure();
    ~IMXSExposure();
}; 

You can see VS was clever enough to find the file to include based on the class we inherit from. Still, I prefer replacing that with a shorter and more descriptive <ifnpub.h>. The 'shorter' part is rather obvious (it's in the include path so no problems with discovery here), as for 'descriptive', I'm talking about the angle brackets here that signify it's a non-local header file.

We don't need the explicit destructor either for our use case, there's no mess we'd be leaving behind. And since we already deleted that one from the .cpp file before, that's one error going away. To get rid of the other one, we'll inline the constructor (that's just to make things simpler for us, though, it won't be a small and nice method where it'd make sense from the optimization viewpoint). If there's no constructor added in your file, write a new one – do note that you want it to come after the public access specifier. Add one if it isn't there – by default if it wasn't specified, it'd be private for C++ classes.

Finally, I'll add the base class constructor explicitly, and now it should look something like (don't miss the final empty pair of braces):

#pragma once
#include <ifnpub.h>
 
class IMXSExposure : public FPInterfaceDesc
{
public:
    IMXSExposure() : FPInterfaceDesc() { };
};

Since including a bare header #include like this will again trigger the Code Analysis, the warnings are back again. But now we want to only disable them for the SDK includes and restore right after that so that our code gets checked. For that, let's make a pair of macros that explain the intent. Right-click the Header Files folder, Add > New Item... and pick Header file (.h). First one will be called warnings_disabled.h; delete the #pragma once directive – we want this to be always inserted – and paste this instead:

#include <codeanalysis\warnings.h>
#pragma warning (push)
#pragma warning (disable : ALL_CODE_ANALYSIS_WARNINGS)

For the other new file, warnings_restored.h, the whole contents of the file will be (again without the #pragma once):

#pragma warning (pop)

After you wrap the SDK #includes with these two headers, this is what the file looks like now:

#pragma once
#include "warnings_disabled.h"
#include <ifnpub.h>
#include "warnings_restored.h"

class IMXSExposure : public FPInterfaceDesc
{
public:
    IMXSExposure() : FPInterfaceDesc() { };
};

Is there a better way? That depends, there are experimental command line switches to set warning level for external headers (you can set it to zero to eliminate them) that you can add the switch to project properties, C\C++ > Command Line > Additional Options; for instance:

/experimental:external /external:anglebrackets /external:W0 

But since that's compiler-level, it won't rid you of the cascade of Intellisense warnings in the Error List. As far as I know, there's nothing like that for the built-in analyzer – though supposedly it could be supported in the future, once the option gets out of experimental.

Core Interface Instance

Try to build and run to see if you did everything right. Nothing changes on max side, we still haven't registered our interface – it doesn't even have a name. That's the next step (unchanged code in gray):

#pragma once
#include "warnings_disabled.h"
#include <ifnpub.h>
#include "warnings_restored.h"
 
extern const Interface_ID MXSEXPOSURE_INTERFACE_ID; // not specified here, just made available for callers
 
class IMXSExposure : public FPInterfaceDesc
{
public:
    IMXSExposure() :
        FPInterfaceDesc(
            MXSEXPOSURE_INTERFACE_ID, _M("MXSExposure"), 0, nullptr, FP_CORE
            , p_end) { };
};

What happened here? There's multiple things needed to register it: a unique interface ID (that's the MXSEXPOSURE_INTERFACE_ID placeholder), name for the interface which will be different from the exposed plugin className (_M("MXSExposure"), the _M macro makes it conform with how unicode is handled now in max), the zero stands for 'no id to lookup in the .rc stringtable' (if not zero, it would be a localizable description which we don't provide here), the nullptr is in place of a pointer to ClassDesc2 (not needed for core interfaces), FP_CORE is a flag that describe the type of the interface (core for interface that can be called directly, perfect for general purpose methods), and the p_end marks the end of the parameters you were giving to the base class constructor.

Let's look at the other new line. The const Interface_ID says this is a value that won't change (you will often see #define used for that purpose in older code), and extern makes the compiler look elsewhere for the actual definition. Where macros would have been used before, you'll now often see const or contexpr, and while there are certain differences, the rule I stick to make them show the intent behind using one or the other. I.e. extern static const in header file for something you want to make available but don't really need to know the actual value, and constexpr for something you initialize on the spot with a meaningful value that gives you some information (say number of ticks in a second).

If you build now, you still won't see anything new when in max – for that, we need to assign the ID and make an instance of the IMXSExposure class first. Switch to the .cpp file and do just that:

#include "IMXSExposure.h"
 
const Interface_ID MXSEXPOSURE_INTERFACE_ID(0x445ddd85, 0x24dd2aa1);
static IMXSExposure interfaceSingleton;

Running the built solution after that should result in the empty interface being successfully registered:

Since you will get warnings here (if using the C++ Core Guidelines checker) that you can't act on, let me stray a bit aside once more and mention another way to suppress them – and that's the suppress warning specifier that applies to the following line only:

#include "IMXSExposure.h"
 
#pragma warning(suppress: 26426) // non-constexpr constructor
const Interface_ID MXSEXPOSURE_INTERFACE_ID(0x445ddd85, 0x24dd2aa1);
#pragma warning(suppress: 26426) // non-constexpr constructor
static IMXSExposure interfaceSingleton;

Done this way, it serves to document that we acknowledge the warning a knowingly chose to suppress it for this particular piece of code.


Interface Methods

Same as with folder structure, there are no strict rules or generally accepted guidelines when it comes to code style and naming conventions either (and the same is true of whitespace). I stick with naming public methods and member variables with a starting capital (HasMapChannel), private member variables and helper functions not declared in header files with initial lowercase letter (bendAroundX), and macros and constants (used the way #define would be used before) all-caps snake case (MXSEXPOSURE_INTERFACE_ID). To prepare for putting in the methods, an enumeration of identifiers for each function is needed, as well as a rather complex block of code which is fortunately handled for us if we use the FUNCTION_MAP macros:

#pragma once
#include "warnings_disabled.h"
#include <ifnpub.h>
#include "warnings_restored.h"

extern const Interface_ID MXSEXPOSURE_INTERFACE_ID; // not specified here, just made available for callers
 
class IMXSExposure : public FPInterfaceDesc
{
public:
    enum functionID : FunctionID { };
 
    IMXSExposure() :
        FPInterfaceDesc(
            MXSEXPOSURE_INTERFACE_ID, _M("MXSExposure"), 0, nullptr, FP_CORE
            , p_end) { };
 
    BEGIN_FUNCTION_MAP
    END_FUNCTION_MAP
};

I'll be making a UV Seam Select modifier, and for that I need to be able to select some edges first. To do that, it's enough to set the proper selection level of the mesh you get inside a simpleMeshMod. Since the function will directly modify the mesh and won't have anything to return, the return type is void, and the parameters are the Mesh pointer and the desired level. The resulting method won't modify the interface class in any way so it can also be declared as a const method (you will see it used in the samples and headers and helps being aware of the meaning behind that):

void SetMeshSelLevel(Mesh* mesh, int level) const;

Inside the function map block, we'll use a VFN_2 macro for Void FuNction with 2 parameters, and give it an ID (see the functionID enum), the function itself, and the types of the two arguments.

VFN_2(setMeshSelLevel_id, SetMeshSelLevel, TYPE_MESH, TYPE_INT)

Finally, inside the constructor body call the AppendFunction method, giving it the function ID, the name that you want to use when calling it, zero for no resource description string, the return type, flags (we'll get to those later) and the number of parameters that will follow. You could have put everything into the FPInterfaceDesc constructor but doing it this way, split into separate function calls, it's easier to edit and harder to screw up, while a bit more verbose.

setMeshSelLevel_id, _M("SetMeshSelLevel"), 0, TYPE_VOID, 0, 2

Each of the parameters lists a name, resource string (zero again) and its type.

    , _M("mesh"), 0, TYPE_MESH
    , _M("level"), 0, TYPE_INT

The argument list always ends with the p_end tag.

Don't forget to add the setMeshSelLevel_id to the functionID enum.
Putting it all together:

#pragma once
#include "warnings_disabled.h"
#include <ifnpub.h>
#include "warnings_restored.h"
 
extern const Interface_ID MXSEXPOSURE_INTERFACE_ID; // not specified here, just made available for callers
 
class IMXSExposure : public FPInterfaceDesc
{
public:
    enum functionID : FunctionID {
        setMeshSelLevel_id,
    };
 
    IMXSExposure() :
        FPInterfaceDesc(
            MXSEXPOSURE_INTERFACE_ID, _M("MXSExposure"), 0, nullptr, FP_CORE
            , p_end) {
        AppendFunction(
            setMeshSelLevel_id, _M("SetMeshSelLevel"), 0, TYPE_VOID, 0, 2
            , _M("mesh"), 0, TYPE_MESH
            , _M("level"), 0, TYPE_INT
            , p_end);
    };
 
    BEGIN_FUNCTION_MAP
        VFN_2(setMeshSelLevel_id, SetMeshSelLevel, TYPE_MESH, TYPE_INT)
    END_FUNCTION_MAP
 
    void SetMeshSelLevel(Mesh* mesh, int level) const;
};

And because I'm a lazy guy, I also put in two convenience macros to save me from copy-pasting and editing everything by hand anytime I rename a function. It's not perfect, the identifier inside the enum won't be autorenamed, but the other ones will and for me that's enough to justify that.

#pragma once
#include "warnings_disabled.h"
#include <ifnpub.h>
#include "warnings_restored.h"
 
#define FUNC_STR(func) _M(#func) // stringifies the passed variable
#define FUNC_ID(func) func##_id  // concatenates the variable name with _id
 
extern const Interface_ID MXSEXPOSURE_INTERFACE_ID; // not specified here, just made available for callers
 
class IMXSExposure : public FPInterfaceDesc
{
public:
    enum functionID : FunctionID {
        FUNC_ID(SetMeshSelLevel),
    };
 
    IMXSExposure() :
        FPInterfaceDesc(
            MXSEXPOSURE_INTERFACE_ID, _M("MXSExposure"), 0, nullptr, FP_CORE
            , p_end) {
        AppendFunction(
            FUNC_ID(SetMeshSelLevel), FUNC_STR(SetMeshSelLevel), 0, TYPE_VOID, 0, 2
            , _M("mesh"), 0, TYPE_MESH
            , _M("level"), 0, TYPE_INT
            , p_end);
    };
 
    BEGIN_FUNCTION_MAP
        VFN_2(FUNC_ID(SetMeshSelLevel), SetMeshSelLevel, TYPE_MESH, TYPE_INT)
    END_FUNCTION_MAP
 
    void SetMeshSelLevel(Mesh* mesh, int level) const;
};

Time to add the function definition. Unsurprisingly, you can do that from Quick Actions (or Alt-Enter if you're on the line of the method declaration) > Create Definition of 'SetMeshSelLevel' in matching.cpp. If you want to do that by hand to get a feel for it, copy-paste the declaration into your cpp file, prepend ClassName:: before the method name and you add a body instead of the semicolon. I've also taken the liberty to add a check if the pointer we received is valid (you can compare it to nullptr instead, both ways are idiomatic):

#include "IMXSExposure.h"
 
#pragma warning(suppress: 26426) // non-constexpr constructor
const Interface_ID MXSEXPOSURE_INTERFACE_ID(0x445ddd85, 0x24dd2aa1);
#pragma warning(suppress: 26426) // non-constexpr constructor
static IMXSExposure interfaceSingleton;
 
void IMXSExposure::SetMeshSelLevel(Mesh* mesh, int level) const {
    if (!mesh) return;
}

However, if you now start writing code expecting to get autocomplete suggestions for the mesh methods, there's bad news looming – you'll only get a pointer to incomplete class type is not allowed error. Reason behind that is that there's no #include that'd offer the necessary info. How do you find the right one? In this case, Visual Studio comes to the rescue again - you can see the Mesh type is properly highlighted and when you hover over it and press CTRL, wait for it to turn into hyperlink and click that, you'll be redirected to the right header file - mesh.h in this case. Let's #include it, within the guarding disable/restore includes pair to prevent warning creep, and make the function actually do something:

#include "warnings_disabled.h"
#include <mesh.h>
#include "warnings_restored.h"
#include "IMXSExposure.h"
 
#pragma warning(suppress: 26426) // non-constexpr constructor
const Interface_ID MXSEXPOSURE_INTERFACE_ID(0x445ddd85, 0x24dd2aa1);
#pragma warning(suppress: 26426) // non-constexpr constructor
static IMXSExposure interfaceSingleton;
 
void IMXSExposure::SetMeshSelLevel(Mesh* mesh, int level) const {
    if (mesh) mesh->selLevel = level;
}

If you've never been exposed to pointers before, it's time to get familiar with them. There's a great Introduction to Pointers for C++ that should help you understand the basics. Don't despair if you don't immediately get them, what you need to get started pretty much boils down to:

  • when passing arguments to a function, if it's not a pointer or a reference, an independent copy will be made (don't let arrays fool you, they're pointers in 'disguise'):
  • int *ints = new int[5]; // dynamically allocates memory for 5 integers
    
  • if you have a pointer variable (like Mesh* mesh here), use the arrow operator as you would use the dot operator:
  • auto objectState = node->EvalWorldState(ip->GetTime(), true); // node and ip are pointers
    
  • prefer instantiating objects with automatic storage duration (so that the destructor - if public - is automatically called when it goes out of scope):
  • MNMesh poly(*mesh); // local variable that you better not return as a pointer, intialized with a dereferenced pointer
    poly.MakePolyMesh(0, false); // using the dot operator
  • in the context of returning a pointer to MAXScript (as a new object taken care of MXS garbage collector in the future), don't hesitate to use new:
  • auto newMesh = new Mesh(*mesh); // make a copy from the mesh pointer
    ...
    return newMesh;
  • & and * are inverse to each other (unless either of them is overloaded); if p is a pointer &*p will be that same pointer again. This is best presented in a table form, as taken from cplusplus.com tutorial on classes:

  • expressioncan be read as
    *xpointed to by x
    &xaddress of x
    x.ymember y of object x
    x->ymember y of object pointed to by x
    (*x).ymember y of object pointed to by x (equivalent to the previous one)
  • if you want to express the possibility of passing no object, you can use pointer (and pass nullptr), otherwise references are preferable (const ones for input-only parameters)

  • I'll try and show some common scenarios with two more example functions soon, that may help better than just learning the theory of it.

    Rebuilding the project finally gets us somewhere:

    And just like that, we can make our first SDK-enabled modifier using the function to do what we couldn't do with pure MAXScript before. For now, it will just select all the edges:

    plugin simpleMeshMod UVSeamSelect
        name:"UV Seam Select"
        classID:#(0x34c4905c, 0x106b1548)
    (
        on modifyMesh do
        (
            setEdgeSelection mesh (mesh.edges as bitArray)
            MXSExposure.SetMeshSelLevel mesh 8
        )
    )
    

    As you've probably noticed, I'm using magic number eight for edge mode – no worries, that's yet another thing that we'll rectify later on. Why eight? Mesh selection level bit flag for edges level is 1<<3 (MAXscript equivalent would be bit.shift 1 3) – why they're flags in the first place, I have no idea. Using hardcoded values like this is okay for quick and dirty tests, but it shouldn't find its way to production environment.

    There are other things you may question, and question them you should as I wouldn't like to encourage cargo-cult programming here. For example, the only reason I place asterisk adjacent to the type instead of the variable name is that that way it's easier for me to tell at a quick glance that I'm using a pointer at all – I know it doesn't make a type, I don't declare multiple pointers per line ever anyway, and the 'space from each side' variant throws me off every time.

    Another thing that may catch your eye are the lines that begin with comma – and there are some vocal proponents of that style. For me personally, it's just another thing that makes the code easier to read at times (I don't use it universally, say for small enums etc) – and in the case of the parameter types that follow the function specification here, the style used throughout the SDK is another level of indentation instead. As all of the above, give it some thought and choose what suits you best.


    Static Interface

    With that achievement under the belt, let's go back to the theory for a while and see how Static Interfaces fit into the picture.

    Fortunately, there are not that many differences between the two approaches that it would necessitate a complete makeover. We can easily convert our current project to use Static Interface instead. The plugin name will revert back to MXSExposure without the -Util suffix, and the IMXSExposure class will turn into IMeshTools one.

    Underlined are the InternalName and ClassName of the utility plugin inside the MXSExposure.cpp that need to reflect the desired name now, and the GetDesc function which returns the pointer to the ClassDesc instance itself that we will need to get:

    That's the function we want to call; only if you go to the matching header file (MXSExposure.h), you'll find it's not declared there - and even if it was, we wouldn't want to include this one as it turns off the Code Analysis warnings without saving the previous state beforehand. The thing is, we don't need to.

    Let's make our own specialized header file (Header Files > Add > New Item...), I'll append 'Desc' to the filename making it MXSExposureDesc in the context of my project. Copy the function signature and paste it into the empty header file and add a semicolon at the end (function declaration needs one, just like statements do). And if VS complains about not being able to recognize the ClassDesc2 type, CTRL+click through the type from the function definition next to the original class to see it's declared in iparamb2.h, and #include that. That will work:

    #pragma once
    #include <iparamb2.h>
     
    ClassDesc2* GetMXSExposureDesc();

    Those of you who are already familiar with the language may note that since the return type is a pointer, you could just as well forward declare class ClassDesc2; instead:

    Those of you who are already familiar with the language may note that since the return type is a pointer, you might forward declare class ClassDesc2; instead – well, yes and no and above all – don't make it needlessly hard on your yourself. For one, if the header file in question would be #included elsewhere within the same translation unit, it won't make a difference (on the assumption that it's guarded against multiple includes); this is true with the main class file but not our interface one. But what the FPInterfaceDesc constructor accepts isn't really a pointer to ClassDesc2, it's a pointer to ClassDesc from which it inherits. And even if it wasn't, it's not significant enough to agonize over that. Either way, it may have shed some light on why you'll often see class declarations like this elsewhere.

    With that settled, let's include this header into the file where the GetDesc function is defined. This way, we've exposed the function to whomever who now #includes this header.


    Now that you've done that, #include it into the IMeshTools class file. In place of the nullptr, use the pointer returned by the GetDesc function. The flag FP_STATIC_METHODS signifies that the interface can be called on the supplied ClassDesc itself, i.e. it doesn't need an instance of an object created by the ClassDesc. That's the GlobalUtilityPlugin that we gave the 'Util' suffix when using Core Interface because we wouldn't be using it. Here it will group all the interfaces under one roof for us.

    The edited IMeshTools file should now look something like this:

    #pragma once
    #include "warnings_disabled.h"
    #include <ifnpub.h>
    #include "warnings_restored.h"
    #include "MXSExposureDesc.h"
     
    #define FUNC_STR(func) _M(#func) // stringifies the passed variable
    #define FUNC_ID(func) func##_id  // concatenates the variable name with _id 
     
    extern const Interface_ID MESHTOOLS_INTERFACE_ID
     
    class IMeshTools : public FPInterfaceDesc
    {
    public:
        enum functionId : FunctionID {
            FUNC_ID(SetMeshSelLevel),
        };
     
        IMeshTools() :
            FPInterfaceDesc(
                MESHTOOLS_INTERFACE_ID, _M("MeshTools"), 0, GetMXSExposureDesc(), FP_STATIC_METHODS
                , p_end) {
            AppendFunction(
                FUNC_ID(SetMeshSelLevel), FUNC_STR(SetMeshSelLevel), 0, TYPE_VOID, 0, 2
                , _M("mesh"), 0, TYPE_MESH
                , _M("level"), 0, TYPE_INT
                , p_end);
        };
     
        BEGIN_FUNCTION_MAP
            VFN_2(FUNC_ID(SetMeshSelLevel), SetMeshSelLevel, TYPE_MESH, TYPE_INT)
        END_FUNCTION_MAP
     
        void SetMeshSelLevel(Mesh* mesh, int level) const;
    };

    After building the project and running max, you should be able to query and access it:


    It's up to you whether you want to continue adding the methods into the static or core interface version of the project. As you can see there's no difference apart from the way how the interfaces are accessed. All my code samples that will follow will use the core interface.


    Interface Methods Continued

    For the next method to make the UV Seam Select work, we need some way to get the seam edges themselves. We could set the selection directly in the Mesh we receive and return void but for the sake of variety let's return a BitArray that will contain the seam edge indices. For parameter types, the actual Mesh is needed, as well as the map channel we'd like to get the edges for:

    BitArray* GetMeshMapSeams(Mesh* mesh, int mapChannel) const;
    

    For the function map, we'll use the FN_2 macro since we're returning something now, and that's also the only difference from the VFN_2 macro used earlier – before the function itself comes the type of the return value, i.e. TYPE_BITARRAY:

    FN_2(FUNC_ID(GetMeshMapSeams), TYPE_BITARRAY, GetMeshMapSeams, TYPE_MESH, TYPE_INT)
    

    Inside the AppendFunction call, the function description parameters on the first line follow the same pattern as with the previous function, only the flags aren't zero anymore. For the sake of explaining by showing the features I'm using the option to turn off automatic redraw here (FP_NO_REDRAW):

    FUNC_ID(GetMeshMapSeams), FUNC_STR(GetMeshMapSeams), 0, TYPE_BITARRAY, FP_NO_REDRAW, 2
    

    The parameter names and types complete the picture here (as always, p_end tag comes at the very end):

    , _M("mesh"), 0, TYPE_MESH
    , _M("mapChannel"), 0, TYPE_INT
    , p_end);
    

    And don't forget to add an ID to the functionID enum:

    FUNC_ID(GetMeshMapSeams)
    

    This is how it all looks in context of the header file:

    #pragma once
    #include "warnings_disabled.h"
    #include <ifnpub.h>
    #include "warnings_restored.h"
     
    #define FUNC_STR(func) _M(#func) // stringifies the passed variable
    #define FUNC_ID(func) func##_id  // concatenates the variable name with _id
     
    extern const Interface_ID MXSEXPOSURE_INTERFACE_ID; // not specified here, just made available for callers
     
    class IMXSExposure : public FPInterfaceDesc
    {
    public:
        enum functionID : FunctionID {
            FUNC_ID(SetMeshSelLevel),
            FUNC_ID(GetMeshMapSeams),
        };
     
        IMXSExposure() :
            FPInterfaceDesc(
                MXSEXPOSURE_INTERFACE_ID, _M("MXSExposure"), 0, nullptr, FP_CORE
                , p_end) {
            AppendFunction(
                FUNC_ID(SetMeshSelLevel), FUNC_STR(SetMeshSelLevel), 0, TYPE_VOID, 0, 2
                , _M("mesh"), 0, TYPE_MESH
                , _M("level"), 0, TYPE_INT
                , p_end);
            AppendFunction(
                FUNC_ID(GetMeshMapSeams), FUNC_STR(GetMeshMapSeams), 0, TYPE_BITARRAY, FP_NO_REDRAW, 2
                , _M("mesh"), 0, TYPE_MESH
                , _M("mapChannel"), 0, TYPE_INT
                , p_end);
        }
     
        BEGIN_FUNCTION_MAP
            VFN_2(FUNC_ID(SetMeshSelLevel), SetMeshSelLevel, TYPE_MESH, TYPE_INT)
            FN_2(FUNC_ID(GetMeshMapSeams), TYPE_BITARRAY, GetMeshMapSeams, TYPE_MESH, TYPE_INT)
        END_FUNCTION_MAP 
    
        void SetMeshSelLevel(Mesh* mesh, int level) const;
        BitArray* GetMeshMapSeams(Mesh* mesh, int mapChannel) const;
    };

    Let's add the definition for the method, this is what the FixIts make super easy (note that you can promote the peek view to document if you don't like the tiny workspace):

    The actual implementation of the function will be a tad more complex and educative this time:

    #include "warnings_disabled.h"
    #include <mesh.h>
    #include <mnmesh.h>
    #include "warnings_restored.h"
    #include "IMXSExposure.h"
     
    #pragma warning(suppress: 26426) // non-constexpr constructor
    const Interface_ID MXSEXPOSURE_INTERFACE_ID(0x445ddd85, 0x24dd2aa1);
    #pragma warning(suppress: 26426) // non-constexpr constructor
    static IMXSExposure interfaceSingleton;
     
    void IMXSExposure::SetMeshSelLevel(Mesh* mesh, int level) const {
     if (mesh) mesh->selLevel = level;
    }
    
    BitArray* IMXSExposure::GetMeshMapSeams(const Mesh* mesh, int mapChannel) const {
        if (!mesh || !mesh->mapSupport(mapChannel))
            return nullptr; // translates to 'undefined' in MAXScript
     
        MNMesh poly(*mesh); // dereferencing the pointer
        Mesh meshCopy(*mesh);
     
        if (!poly.nume)
            return nullptr; // without edges, there are no seams
     
        poly.ClearEFlags(MN_SEL);
        poly.SetMapSeamFlags(mapChannel);
        poly.PropegateComponentFlags(MNM_SL_EDGE, MN_SEL, MNM_SL_EDGE, MN_EDGE_MAP_SEAM);
        poly.OutToTri(meshCopy);
    
        if (mesh->numFaces != meshCopy.numFaces)
            return nullptr; // topology changed when converting to poly
        
        return new BitArray(meshCopy.edgeSel);
    }

    You may have noticed the mesh argument now points to a const Mesh, because we're not changing the mesh in any way, nor calling any of its methods that might be non-const. If we instead opted for setting the selection directly on the mesh, we'd have to drop the const keyword.

    After the initial short-circuiting null check we also check if the mapChannel is supported and if not, return undefined (if the caller is MAXScript code). You could throw an exception with a descriptive message instead, if you wanted to.

    Next, a new MNMesh is created from the Mesh pointer passed to the method. MNMesh is a winged-edge/polygonal mesh class (i.e. what Editable_Poly is using under the hood), and the constructor accepts a Mesh reference – since we have a pointer, we have to dereference it first. A copy of the original Mesh is made in a similar manner – both are local variables that will be automatically deallocated at the end of the enclosing code block. That also means it wouldn't be a good idea to return their address or address of any of their members as they will be destroyed when the function returns.

    If the number of poly edges is zero, it's time to return undefined again (or throw an exception). It's more descriptive to check against zero but it's better that you expect to see this as it's used pretty often.

    Usually, you'd also call MakePolyMesh on the MNMesh created from triangular Mesh. Yes, you can still have triangle faces with invisible (yet selectable) edges in Editable poly as well - it used to be a longstanding issue of the Quadify Mesh modifier (you had to add Turn to Poly or convert to mesh, then to poly again to get rid of the invisible edges). Since we don't care about this and only want to query some edge flags, we can safely skip that step.

    To guard against the case any of the edges were already selected, it's better to clear the selection flags. Once that is done, map seam flags for the mapChannel are set, and on the next line propagated to selection flags – selection will be kept during the conversion back to Mesh which is needed here because the indexing is different between the two. As we're done with the poly operations, we can output the updated Mesh to the meshCopy variable.

    Another if test is there to make sure the updated meshCopy has the same number of edges as the mesh we want to apply the edge selection to in the end. The local meshCopy uses the dot operator directly, for the pointer variable we have to use the arrow operator (or dereference manually and then use the dot operator). For mesh, number of edges is always number of faces times three so the comparison will yield the same result for either.

    At the end, a new BitArray is dynamically allocated to be returned, copying the contents of the meshCopy edge selection. As you remember, meshCopy is local to the function body and will be destroyed once out of scope.

    // can be returned by value (a copy is made, local is destroyed)
    // but that's not very efficient; and MXS expects a *pointer val
    // for most of reasonably complex types, primitive values are OK
    // if you returned &localVar address, it would point to garbage 
    BitArray localVar(size);
     
    // can return straight to MAXScript, comes with responsibility
    // if method gives you pointer it may come with 'needDel' bool
    // if true, you are the one who is responsible for the cleanup
    BitArray* dynamicVar = new BitArray(size);

    To clarify why checking if the face counts are the same was needed in the first place, I'd like to explain a few peculiarities that mesh allows, which are impossible in poly and would get lost in translation. For example, the Teapot primitive is built with rows of face pairs that share an invisible edges to form a quad. Rather than have a special case to generate triangles on the lid top (and the body bottom), the 'quads' in the last row are made by one regular triangular face and one two-vert face (and they still share invisible edges). The two-vert face has three edges like all the other faces, it's just that for one of the edges both its vertex indices point to the same vertex, the top one.

    This is illegal in poly world so you'd get a random looking result if you didn't check for this case and applied the renumbered edge selection. Null edges are not the only possible issue; for example poly edges can have at maximum two adjacent faces – you can't take an edge in the middle of a surface and shift-drag it to create new face, that only works if it's open edge. In meshes two verts can be shared among an unlimited number of faces.

    The main reason I chose to go the poly route here – rather than with an uneventful and safe loop without loopholes – was to illustrate some roadblocks you might encounter along the way, which will become obvious once you try to build the project. And though I didn't mention #including mnmesh.h this time around, I trust you enough to figure that out. Let's try and see what happens:

    1>IMXSExposure.obj : error LNK2019: unresolved external symbol "public: virtual __cdecl MNMesh::~MNMesh(void)" (??1MNMesh@@UEAA@XZ) referenced in function "public: class BitArray * __cdecl IMXSExposure::GetMeshMapSeams(class Mesh *,int)const " (?GetMeshMapSeams@IMXSExposure@@QEBAPEAVBitArray@@PEAVMesh@@H@Z)
    1>IMXSExposure.obj : error LNK2019: unresolved external symbol "public: void __cdecl MNMesh::Init(void)" (?Init@MNMesh@@QEAAXXZ) referenced in function "public: __cdecl MNMesh::MNMesh(class Mesh const &)" (??0MNMesh@@QEAA@AEBVMesh@@@Z)
    1>IMXSExposure.obj : error LNK2019: unresolved external symbol "public: void __cdecl MNMesh::Clear(void)" (?Clear@MNMesh@@QEAAXXZ) referenced in function "public: void __cdecl MNMesh::SetFromTri(class Mesh const &)" (?SetFromTri@MNMesh@@QEAAXAEBVMesh@@@Z)
    1>IMXSExposure.obj : error LNK2019: unresolved external symbol "public: int __cdecl MNMesh::PropegateComponentFlags(int,unsigned long,int,unsigned long,bool,bool)" (?PropegateComponentFlags@MNMesh@@QEAAHHKHK_N0@Z) referenced in function "public: class BitArray * __cdecl IMXSExposure::GetMeshMapSeams(class Mesh *,int)const " (?GetMeshMapSeams@IMXSExposure@@QEBAPEAVBitArray@@PEAVMesh@@H@Z)
    1>IMXSExposure.obj : error LNK2019: unresolved external symbol "public: void __cdecl MNMesh::AddTri(class Mesh const &)" (?AddTri@MNMesh@@QEAAXAEBVMesh@@@Z) referenced in function "public: void __cdecl MNMesh::SetFromTri(class Mesh const &)" (?SetFromTri@MNMesh@@QEAAXAEBVMesh@@@Z)
    1>IMXSExposure.obj : error LNK2019: unresolved external symbol "public: void __cdecl MNMesh::OutToTri(class Mesh &)" (?OutToTri@MNMesh@@QEAAXAEAVMesh@@@Z) referenced in function "public: class BitArray * __cdecl IMXSExposure::GetMeshMapSeams(class Mesh *,int)const " (?GetMeshMapSeams@IMXSExposure@@QEBAPEAVBitArray@@PEAVMesh@@H@Z)
    1>IMXSExposure.obj : error LNK2019: unresolved external symbol "public: void __cdecl MNMesh::FillInMesh(void)" (?FillInMesh@MNMesh@@QEAAXXZ) referenced in function "public: __cdecl MNMesh::MNMesh(class Mesh const &)" (??0MNMesh@@QEAA@AEBVMesh@@@Z)
    1>IMXSExposure.obj : error LNK2019: unresolved external symbol "public: void __cdecl MNMesh::SetMapSeamFlags(int)" (?SetMapSeamFlags@MNMesh@@QEAAXH@Z) referenced in function "public: class BitArray * __cdecl IMXSExposure::GetMeshMapSeams(class Mesh *,int)const " (?GetMeshMapSeams@IMXSExposure@@QEBAPEAVBitArray@@PEAVMesh@@H@Z)
    1>IMXSExposure.obj : error LNK2001: unresolved external symbol "public: virtual class BaseInterface * __cdecl MNMesh::GetInterface(class Interface_ID)" (?GetInterface@MNMesh@@UEAAPEAVBaseInterface@@VInterface_ID@@@Z)
    1>MXSExposure.gup : fatal error LNK1120: 9 unresolved externals
    

    When you look up LNK2019 in the build reference, the first possible cause listed tells you 'The object file or library that contains the definition of the symbol is not linked'. This is what the .lib files from the lib folder are for, and the basic set is already pre-filled by the wizard when you first set up the project. There are quite a few ways to look up which one is missing; I'll use the good old command prompt. Open Windows Explorer and navigate to the lib folder. Click in the address bar and paste this line:

    cmd /k for %i in (*.lib) do dumpbin /exports %i | findstr /i SetMapSeamFlags
    

    It opens the command line prompt and runs the command after the /k switch, which in turn calls dumpbin.exe to list all exported definitions for each of the .lib files, and the result is filtered by findstr matching against 'SetMapSeamFlags' (that's the method we wanted to use, and one of the unresolved symbols listed in the build Output) while /ignoring case. For that to work, you have to either give it the full path to dumpbin executable or add the dumpbin path to the PATH environment variable. To find it, go to the Microsoft Visual Studio folder in Program Files (x86) and search for dumbin.exe; either one should work, I used the path to the HostX64\x64 one.

    As soon as you run the command, you should get a long list of empty result lines with only one .lib file matching the query:


    Open the project properties and navigate to Linker > Input > Additional Dependencies and add the lib file to the list – either by directly clicking into the line and editing it or through the Edit... from the dropdown. The project should now build without any issues.

    That means that right after testing and confirming that it works, we should be able to use it in the modifier:

    plugin simpleMeshMod UVSeamSelect
        name:"UV Seam Select"
        classID:#(0x34c4905c, 0x106b1548)
    (
        parameters main rollout:params
        (
            mapChannel type:#integer ui:spnMapChannel default:1
        )
    
        rollout params "Parameters"
        (
            spinner spnMapChannel "Map Channel:" type:#integer range:[1,99,1]
        )
    
        on modifyMesh do
        (
            local channelEdges = MXSExposure.GetMeshMapSeams mesh mapChannel
            if channelEdges != undefined do setEdgeSelection mesh channelEdges
            MXSExposure.SetMeshSelLevel mesh 8
        )
    )
    

    Indeed, the selection is set and passed up the stack, so you can use it for example to extrude the map seam edges:


    THE Core Interface

    These were both rather straightforward functions, but sometimes you need additional input not given by the user. An example would be getting the current time for time-dependent functions. As usual, there are many ways to go about that, and I will start with the more universal one to illustrate the concept of using COREInterface methods.

    GetCOREInterface()/UtilGetCOREInterface() (and their subsequent numbered variantes added in later max versions) give you access to the interface for calling various useful methods.As such, it's useful enough that you'll see the 'ip' variable scattered all across the SDK sample files.

    Since you'd likely be using it repeatedly, you could use a static variable inside the function, but it's highly probable that more functions would use it – having the ip accessible as a private class member certainly sounds handy:

    #pragma once
    #include "warnings_disabled.h"
    #include <ifnpub.h>
    #include <systemutilities.h>
    #include "warnings_restored.h"
     
    #define FUNC_STR(func) _M(#func) // stringifies the passed variable
    #define FUNC_ID(func) func##_id // concatenates the variable name with _id 
     
    extern const Interface_ID MXSEXPOSURE_INTERFACE_ID; // not specified here, just made available for callers
     
    class IMXSExposure : public FPInterfaceDesc
    {
        Interface* ip = UtilGetCOREInterface();
    public:
        enum functionId : FunctionID {
            FUNC_ID(SetMeshSelLevel),
            FUNC_ID(GetMeshMapSeams),
        };
     
        IMXSExposure() :
            FPInterfaceDesc(
                MXSEXPOSURE_INTERFACE_ID, _M("MXSExposure"), 0, nullptr, FP_CORE + FP_STATIC_METHODS
                , p_end) {
            AppendFunction(
                FUNC_ID(SetMeshSelLevel), FUNC_STR(SetMeshSelLevel), 0, TYPE_VOID, 0, 2
                , _M("mesh"), 0, TYPE_MESH
                , _M("level"), 0, TYPE_INT
                , p_end);
            AppendFunction(FUNC_ID(GetMeshMapSeams), FUNC_STR(GetMeshMapSeams), 0, TYPE_BITARRAY, FP_NO_REDRAW, 2
                , _M("mesh"), 0, TYPE_MESH
                , _M("mapChannel"), 0, TYPE_INT
                , p_end);
        };
     
        BEGIN_FUNCTION_MAP
            VFN_2(FUNC_ID(SetMeshSelLevel), SetMeshSelLevel, TYPE_MESH, TYPE_INT)
            FN_2(FUNC_ID(GetMeshMapSeams), TYPE_BITARRAY, GetMeshMapSeams, TYPE_MESH, TYPE_INT)
        END_FUNCTION_MAP
     
        void SetMeshSelLevel(Mesh* mesh, int level) const;
        BitArray* GetMeshMapSeams(const Mesh * mesh, int mapChannel) const;
    }; 
    

    First the question of the #include and which one to choose – totally up to you. I went with this one as I'm also using mesh.h which #includes utillib.h where systemutilities.h is #include, too – but in the future, I may need to process scene nodes and will add inode.h which uses GetCOREInterface.h, and the point would be moot... It's good to be aware that the choice is there, yet nothing bad will happen if you pick either of the two. Second, you see I'm initializing the variable right as I declare it – while this wasn't possible before, since C++11 you can initialize member variables in-class like this – no reason to shy away from that, especially if that makes your code easier to read.

    Time to add a method, that will need that interface – and in our example, that will be a simple ripple deform with a wave shift over time:

    void RippleMeshDeform(Mesh* mesh, const Point3& center, float speed, float frequency, float amplitude);
    

    As you can see from its signature, it doesn't return anything, it operates on the given mesh and in addition to that and a few numeric variables to control wave frequency, amplitude and shift speed, it expects a Point3 value by reference (const meaning we promise not to change it so function calls are forbidden, property access allowed). Now, my motivation behind this choice was educative first and foremost but fact is that using it this way is not unheard of (point in case being several Edit Poly modifier methods). You can choose if you want to use by-reference parameters, and if you do, you have to also pick a matching type for the function map macro:

    VFN_5(FUNC_ID(RippleMeshDeform), RippleMeshDeform, TYPE_MESH, TYPE_POINT3_BR, TYPE_FLOAT, TYPE_FLOAT, TYPE_FLOAT)
    

    The _BR suffix in TYPE_POINT3_BR identifies pass-by-reference. There're others like _BV and _BP (by value and by pointer), _TAB for collections of the types, and combinations of the two. Refer to the Supported Types and Added Base Types chapters for more.

    The rest follows the same pattern as before:

    #pragma once
    #include "warnings_disabled.h"
    #include <ifnpub.h>
    #include <systemutilities.h>
    #include "warnings_restored.h"
     
    #define FUNC_STR(func) _M(#func) // stringifies the passed variable
    #define FUNC_ID(func) func##_id // concatenates the variable name with _id 
     
    extern const Interface_ID MXSEXPOSURE_INTERFACE_ID; // not specified here, just made available for callers
     
    class IMXSExposure : public FPInterfaceDesc
    {
        Interface* ip = UtilGetCOREInterface();
    public:
        enum functionId : FunctionID {
            FUNC_ID(SetMeshSelLevel),
            FUNC_ID(GetMeshMapSeams),
            FUNC_ID(RippleMeshDeform),
        };
     
        IMXSExposure() :
            FPInterfaceDesc(
                MXSEXPOSURE_INTERFACE_ID, _M("MXSExposure"), 0, nullptr, FP_CORE + FP_STATIC_METHODS
                , p_end) {
            AppendFunction(
                FUNC_ID(SetMeshSelLevel), FUNC_STR(SetMeshSelLevel), 0, TYPE_VOID, 0, 2
                , _M("mesh"), 0, TYPE_MESH
                , _M("level"), 0, TYPE_INT
                , p_end);
            AppendFunction(FUNC_ID(GetMeshMapSeams), FUNC_STR(GetMeshMapSeams), 0, TYPE_BITARRAY, FP_NO_REDRAW, 2
                , _M("mesh"), 0, TYPE_MESH
                , _M("mapChannel"), 0, TYPE_INT
                , p_end);
            AppendFunction(
                FUNC_ID(RippleMeshDeform), FUNC_STR(RippleMeshDeform), 0, TYPE_VOID, 0, 5
                , _M("mesh"), 0, TYPE_MESH
                , _M("center"), 0, TYPE_POINT3_BR
                , _M("speed"), 0, TYPE_FLOAT
                , _M("frequency"), 0, TYPE_FLOAT
                , _M("amplitude"), 0, TYPE_FLOAT
                , p_end);
        };
     
        BEGIN_FUNCTION_MAP
            VFN_2(FUNC_ID(SetMeshSelLevel), SetMeshSelLevel, TYPE_MESH, TYPE_INT)
            FN_2(FUNC_ID(GetMeshMapSeams), TYPE_BITARRAY, GetMeshMapSeams, TYPE_MESH, TYPE_INT)
                    VFN_5(FUNC_ID(RippleMeshDeform), RippleMeshDeform, TYPE_MESH, TYPE_POINT3_BR, TYPE_FLOAT, TYPE_FLOAT, TYPE_FLOAT)
        END_FUNCTION_MAP
     
        void SetMeshSelLevel(Mesh* mesh, int level) const;
        BitArray* GetMeshMapSeams(const Mesh * mesh, int mapChannel) const;
        void RippleMeshDeform(Mesh* mesh, const Point3& center, float speed, float frequency, float amplitude);
    };
    

    As for the actual function implementation, it starts with the usual null check and after we're sure there are any verts to deform, we get to use the ip pointer. To be able to call any of the functions of the interface, the maxapi header has to be present – don't worry, Visual Studio is very helpful in this regard and will warn you if you forget, and then it's one click-through from the declaration to see what file you need. TicksToSec macro converts the time (which is returned in ticks) for us to a meaningful, self-documenting value that we can then multiply by the desired speed:

    #include "warnings_disabled.h"
    #include <maxapi.h>
    #include <mesh.h>
    #include <mnmesh.h>
    #include "warnings_restored.h"
    #include "IMXSExposure.h"
    
    ... float get2DLength(float x, float y) { return sqrt(x * x + y * y); } void IMXSExposure::RippleMeshDeform(Mesh* mesh, const Point3& center, float speed, float frequency, float amplitude) { if (!mesh || !mesh->numVerts) return; const float shift = TicksToSec(ip->GetTime()) * speed; auto vert = &(mesh->getVert(0)); for (int v = 0, numVerts = mesh->numVerts; v < numVerts; ++v, ++vert) vert->z += amplitude * sin(frequency * get2DLength(vert->x - center.x, vert->y - center.y) - shift); }

    And now let's see if you did your homework and familiarized yourself with pointers. A Mesh is a super simple structure and most of the things there are stored in simple arrays. And if you remember how arrays work, what you get when you receive an array is a pointer to the first item in that array. Increment that pointer and you have a pointer to the second item, and so on. So if you get the first vertex position and take its address, you have the address of the beginning of the array. We could have just as well said auto vert = mesh->verts;

    Now, the for loop is stupid long (and if I were to actually use that, I'd probably split it at each semicolon) and does lot of things at once. The first initialization part is executed only once at the very beginning and sets the counter variable to compare against the total number of verts, and the total number of verts to check against. The second part is the actual comparison being evaluated, this happens repeatedly as a condition before executing the loop body. And the final part increments both the loop counter and the vertex pointer, right after the body is finished executing. There's also range-based loop variant that's worth getting acquainted with but you can't use that when all you have is an array pointer. An elegant solution is using spans – yet that's only available in the Guideline Support Library or if you wait for C++20.

    Did I mention that you can reinterpret_cast the array pointer to from the Point3 type to point to an array of floats? Don't get me wrong, you shouldn't do that, but the possibility is there. Oh, and while we're at it, you'll see C-style casts scattered over the samples and snippets all over the web – the rule with using C-style casts and C++ is simple – don't. When you want to use a cast, chances are you want dynamic_cast (in case you missed this tidbit from the documentation, I'm gonna quote General Best Practices here: Use dynamic_cast instead of using the unsafe static_cast and always check the result for NULL to make sure that it succeeded).

    Back to the loop body – actually, it is in fact pretty uneventful compared to the previous line. The z value of the current vert is incremented by a sin of the horizontal distance from the current vert to the center position multiplied by the frequency and shifted by the time times speed. The get2DLength that calculates the horizontal (or 2D) distance itself is defined right before the RippleMeshDeform function. That's it. Phew.

    And you already know the rest of the drill, CTRL+F5 and put it to the test in Max. For that, I've made another simpleMeshMod:

    plugin simpleMeshMod SimpleRipple
        name:"Simple Ripple"
        classID:#(0x68a11be6, 0x4ec696e8)
        usePBValidity:off -- always trigger update on time change
    (
        parameters main rollout:params
        (
            speed type:#float ui:spnSpeed default:10
            frequency type:#float ui:spnFrequency default:1
            amplitude type:#worldUnits ui:spnAmplitude default:1
            source type:#node ui:pbSource useNodeTmValidity:on
            _dummy type:#maxObjectTab tabSizeVariable:on
        )
    
        rollout params "Parameters"
        (
            pickButton pbSource "NONE" width:140 autoDisplay:on
            spinner spnSpeed "Speed: " type:#float range:[0,1e6,0]
            spinner spnFrequency "Frequency: " type:#float range:[0,1e6,1]
            spinner spnAmplitude "Amplitude: " type:#worldUnits range:[-1e6,1e6,1]
        )
    
        on attachedToNode node do if isValidNode node do
            append this._dummy (NodeTransformMonitor node:node) -- explicitly trigger update whenever node transform changes
    
        on modifyMesh do
        (
            local center = if isValidNode source then (source.transform * inverse owningNode.objectTransform).pos else [0,0,0]
            MXSExposure.RippleMeshDeform mesh center speed frequency amplitude
        )
    )
    

    Since we're always using current time to calculate the wave shift, don't forget to use usePBValidity:off, otherwise an update would only be triggered if any of the params of the parameter block used animated controller at that time. With that in place, the time-dependent changes should work exactly as expected:

    Convenience Features

    So far, we've done all the necessary checks ourselves (if any – SetMeshSelLevel never checks if it got a valid level!). If you were thinking there must be a better way, you're right. Let's start with the GetMeshMapSeams, it would make sense if you weren't allowed to pass a map channel number that's out of the regularly used 1..99 range. To do that, it's enough to add to the parameter in question a f_range tag followed by the lower and upper bounds when appending the function:

    AppendFunction(
     FUNC_ID(GetMeshMapSeams), FUNC_STR(GetMeshMapSeams), 0, TYPE_BITARRAY, FP_NO_REDRAW, 2
     , _M("mesh"), 0, TYPE_MESH
     , _M("mapChannel"), 0, TYPE_INT, f_range, 1, 99
     , p_end);

    Super easy and super handy. A bit limited, though, because sometimes you may want to validate a predefined set of values, values that are not inherently floats or ints, or values like Point3. Because of that, there's also f_validator for your own customized validator function. We'll need to give it a pointer to our validator class instance, and that class we first have to create. I'll call mine Point3Validator.:


    And implement the inherited method right away (via Quick Actions, either through right-click or CTRL+.):


    Let's have a look at an example of how you might approach it:

    #include <point3.h>
    #include "Point3Validator.h"
     
    bool Point3Validator::Validate(FPInterface*, FunctionID, int, FPValue& val, MSTR& msg) {
        const bool valid = isfinite(val.p->x) && isfinite(val.p->y) && isfinite(val.p->z);
        if (!valid) msg = _T("Center must be a valid position.");
        return valid;
    }
     
    Point3Validator* GetPoint3Validator()
    {
        static Point3Validator point3Validator;
        return &point3Validator;
    }

    Since we'll be working with Point3 values, a point3.h header file is needed. For the Validate function itself, the first thing you may notice is that the first three parameters no longer have names – since we won't be using them within the function body, the compiler would warn us about that otherwise (C4100: unreferenced formal parameter), and this way, you can make your intentions clear. Next comes the actual validation, here it is checking if all three parts are finite (neither infinite nor NaN). Since the FPValue is sort of a catch-all container, you have to access its member variable matching to the type you're checking – the p in val.p gives you Point3*. If it's not valid, set the msg to a descriptive message, and return the valid bool.

    As mentioned before, we need a pointer to supply with the f_validator tag, and for that there's the other function, analogous to the GetDesc function we've seen before. Put its declaration to the class header file, and include the header in the interface class file. For completeness sake, here's the updated header file:

    #pragma once
    #include <ifnpub.h>
     
    class Point3Validator : public FPValidator {
        virtual bool Validate(FPInterface* fpi, FunctionID fid, int paramNum, FPValue& val, MSTR& msg) override;
    };
     
    Point3Validator* GetPoint3Validator();

    After that, this should compile and work as expected:

    #pragma once
    #include "warnings_disabled.h"
    #include <ifnpub.h>
    #include <systemutilities.h>
    #include "warnings_restored.h"
    #include "Point3Validator.h"
    
    #define FUNC_STR(func) _M(#func) // stringifies the passed variable
    #define FUNC_ID(func) func##_id // concatenates the variable name with _id 
    
    extern const Interface_ID MXSEXPOSURE_INTERFACE_ID; // not specified here, just made available for callers
    
    class IMXSExposure : public FPInterfaceDesc
    {
        Interface* ip = UtilGetCOREInterface();
    public:
        enum functionId : FunctionID {
            FUNC_ID(SetMeshSelLevel),
            FUNC_ID(GetMeshMapSeams),
            FUNC_ID(RippleMeshDeform),
        };
     
        IMXSExposure() :
            FPInterfaceDesc(
                MXSEXPOSURE_INTERFACE_ID, _M("MXSExposure"), 0, nullptr, FP_CORE + FP_STATIC_METHODS
                , p_end) {
            ...
            AppendFunction(
                FUNC_ID(RippleMeshDeform), FUNC_STR(RippleMeshDeform), 0, TYPE_VOID, 0, 5
                , _M("mesh"), 0, TYPE_MESH
                , _M("center"), 0, TYPE_POINT3_BR, f_validator, GetPoint3Validator()
                , _M("speed"), 0, TYPE_FLOAT
                , _M("frequency"), 0, TYPE_FLOAT
                , _M("amplitude"), 0, TYPE_FLOAT
                , p_end);
        };
     
        BEGIN_FUNCTION_MAP
            VFN_2(FUNC_ID(SetMeshSelLevel), SetMeshSelLevel, TYPE_MESH, TYPE_INT)
            FN_2(FUNC_ID(GetMeshMapSeams), TYPE_BITARRAY, GetMeshMapSeams, TYPE_MESH, TYPE_INT)
            VFN_5(FUNC_ID(RippleMeshDeform), RippleMeshDeform, TYPE_MESH, TYPE_POINT3_BR, TYPE_FLOAT, TYPE_FLOAT, TYPE_FLOAT)
        END_FUNCTION_MAP
     
        void SetMeshSelLevel(Mesh* mesh, int level) const;
        BitArray* GetMeshMapSeams(const Mesh * mesh, int mapChannel) const;
        void RippleMeshDeform(Mesh* mesh, const Point3& center, float speed, float frequency, float amplitude);
    };

    Looking at that showInterface printout, one more thing is bothering me. It says center is In and Out parameter but we never change it, it gives a wrong impression about that. Luckily, there's another tag for that, and the tags can be chained so we can do this to change it to In parameter only:

    AppendFunction(
        FUNC_ID(RippleMeshDeform), FUNC_STR(RippleMeshDeform), 0, TYPE_VOID, 0, 5
        , _M("mesh"), 0, TYPE_MESH
        , _M("center"), 0, TYPE_POINT3_BR, f_inOut, FPP_IN_PARAM, f_validator, GetPoint3Validator()
        , _M("speed"), 0, TYPE_FLOAT
        , _M("frequency"), 0, TYPE_FLOAT
        , _M("amplitude"), 0, TYPE_FLOAT
        , p_end);

    In a similar manner, you can use f_keyArgDefault to mark optional keyword arguments, all you have to do is make sure that no other non-keyword argument follows after in the list of arguments, and you have to supply the default value:

    AppendFunction(
        FUNC_ID(GetMeshMapSeams), FUNC_STR(GetMeshMapSeams), 0, TYPE_BITARRAY, FP_NO_REDRAW, 2
        , _M("mesh"), 0, TYPE_MESH
        , _M("mapChannel"), 0, TYPE_INT, f_range, 1, 99, f_keyArgDefault, 1
        , p_end);

    In this case, it's a rather contrived example but should be enough to give you an idea or two how to work with the tags. Notice also how the center changed to In parameter type only.

    Yet we still haven't done anything about the SetMeshSelLevel argument validation – yes, we could use a validator but there's a better, self-documenting way, and that's to use a symbolic enumeration:

    ...  
    
    class IMXSExposure : public FPInterfaceDesc
    {
        Interface* ip = UtilGetCOREInterface();
    public:
        enum functionId : FunctionID {
            ...,
        };
     
        enum enumID : EnumID {
            selLevelID,
        ...
     
        IMXSExposure() :
            FPInterfaceDesc(
                MXSEXPOSURE_INTERFACE_ID, _M("MXSExposure"), 0, nullptr, FP_CORE + FP_STATIC_METHODS
                , p_end) {
            AppendFunction(
                FUNC_ID(SetMeshSelLevel), FUNC_STR(SetMeshSelLevel), 0, TYPE_VOID, 0, 2
                , _M("mesh"), 0, TYPE_MESH
                , _M("level"), 0, TYPE_ENUM, selLevelID
                , p_end);
            AppendFunction(
                ...);
            AppendEnum(
                selLevelID, 4
                , _M("object"), MESH_OBJECT
                , _M("vertex"), MESH_VERTEX
                , _M("edge"), MESH_EDGE
                , _M("face"), MESH_FACE
                , p_end);
        };
     
        BEGIN_FUNCTION_MAP
            VFN_2(FUNC_ID(SetMeshSelLevel), SetMeshSelLevel, TYPE_MESH, TYPE_ENUM)
            FN_2(FUNC_ID(GetMeshMapSeams), TYPE_BITARRAY, GetMeshMapSeams, TYPE_MESH, TYPE_INT)
            VFN_5(FUNC_ID(RippleMeshDeform), RippleMeshDeform, TYPE_MESH, TYPE_POINT3_BR, TYPE_FLOAT, TYPE_FLOAT, TYPE_FLOAT)
        END_FUNCTION_MAP
     
        void SetMeshSelLevel(Mesh* mesh, int level) const;
        BitArray* GetMeshMapSeams(const Mesh * mesh, int mapChannel) const;
        void RippleMeshDeform(Mesh* mesh, const Point3& center, float speed, float frequency, float amplitude);
    };

    Just like functions, enums have to have IDs, but since the enum definition happens within the AppendEnum call, I'm using a fixed ID. For the AppendEnum, number of members and their names and values have to be passed, and we can directly use the flags from mesh.h for the values here. The only other change is from the TYPE_INT to TYPE_ENUM, and in case of AppendFunction the enum type tag has to be followed by the enum ID. We should finally be able to replace that ugly 8 with something meaningful as promised:


    All these changes happened only within the parameter list, without touching the actual function. However, there's still a territory we haven't covered yet – other specialized function macros outside of FN and VFN. For one, there are macros that you can use to expose properties directly instead of through a getter/setter pair, those are PROP_FNS for aforementioned getter/setter pairing, RO_PROP_FN for readonly property (getter only, note the singular FN), their time-dependent variants PROP_TFNS/RO_PROP_TFN, and static method version of all the previous four with the prefix SM_.

    Then are CFN functions with constant return values (they do a const_cast behind the scenes), never seen those used anywhere. And finally, VFNT_/FNT_ for void/value-returning functions with time parameter, which is the same concept as the time-dependent properties mentioned earlier. In addition to the list of parameters given to the macro, they receive a time parameter – and guess where we could use it? Bingo, turns out we didn't need the Interface variable after all – but don't worry, it may come in handy for other tasks. Now let's update RippleMeshDeform:

     BEGIN_FUNCTION_MAP
         VFN_2(FUNC_ID(SetMeshSelLevel), SetMeshSelLevel, TYPE_MESH, TYPE_ENUM)
         FN_2(FUNC_ID(GetMeshMapSeams), TYPE_BITARRAY, GetMeshMapSeams, TYPE_MESH, TYPE_INT)
         VFNT_5(FUNC_ID(RippleMeshDeform), RippleMeshDeform, TYPE_MESH, TYPE_POINT3_BR, TYPE_FLOAT, TYPE_FLOAT, TYPE_FLOAT)
     END_FUNCTION_MAP
     
     void SetMeshSelLevel(Mesh* mesh, int level) const;
     BitArray* GetMeshMapSeams(const Mesh * mesh, int mapChannel) const;
     void RippleMeshDeform(Mesh* mesh, const Point3& center, float speed, float frequency, float amplitude, const TimeValue& time);
    

    The only two things that changed in the header are the added V in the macro name and a new const TimeValue& time function parameter. On the cpp-side, it would be enough to replace ip->GetTime() with the new time variable...

    void IMXSExposure::RippleMeshDeform(Mesh* mesh, const Point3& center,
        float speed, float frequency, float amplitude, const TimeValue& time)
    {
        if (!mesh || !mesh->numVerts) return;
     
        static auto get2DLength = [](float x, float y) { return sqrt(x * x + y * y); };
        const float shift = TicksToSec(time) * speed;
     
        for (auto vert = mesh->verts, cutoff = vert + mesh->numVerts; vert != cutoff; ++vert)
            vert->z += amplitude * sin(frequency * get2DLength(vert->x - center.x, vert->y - center.y) - shift);
    }

    But since I'm a fan of the old maxim 'train hard, fight easy', here it is with a bit more sensible loop variant – and to balance that, a self-contained lambda replacing the outer helper function. You should be able to make sense of the loop now that you've seen the previous one. Unlike the previous one, it doesn't have the problem that it the vert would be still accessible out of the loop, pointing to a out-of-bounds garbage value should you ever come back to the function and expand it. The lambda is rather unremarkable, if it weren't for it being static – no problems here, but don't do this with capturing lambdas unless you want them to only capture once. Anyhow, that was enough confusion for today, the function works in either form and if you read through the whole post, you deserve a break.

    In conclusion, Function Publishing is extremely flexible and empowering system, even if you only use it for a few critical functions and handle the rest in MAXScript. Don't be afraid to try it out and see for yourself. And most importantly, have fun!

    DISCLAIMER: All scripts and snippets are provided as is under Creative Commons Zero (public domain, no restrictions) license. The author and this blog cannot be held liable for any loss caused as a result of inaccuracy or error within these web pages. Use at your own risk.

    This Post needs Your Comment!

    Joe Scarr

    wow, that rocks, thank-you!

    David

    This is super impressive and overwhelming :)
    I am very exited to try and learn from this.
    Thank you so much!

    Aslan

    Great Stuff here. Thanks for shearing.

    Return to top