More often than not, I reach for .NET in Max just because there's that one property or method not exposed to MAXScript, yet everything else could be easily done MAXScript-side. And when you're trying to move fast and explore ideas, switching apps to compile new libraries and the workarounds needed for hot reloading derail your focus, and feel like total overkill. On-the-fly compilation helps, but you lose syntax highlighting, error offsets become a math puzzle, and the boilerplate often dwarfs the actual logic – especially now with the CodeDomProvider.CompileAssemblyFromSource vs CSharpUtilities.CSharpCompilationHelper split.
Let's have a look at some ways on how I use the .NET API without stepping outside the MAXScript world. While it won't make everything elegant, it shouldn't get all that cumbersome either – and if it ever does, chances are you would be better off writing a proper C++ plugin and skipping the shenanigans altogether anyway.
I'll be using a simple modifier as the example, just like in the C++ post. Let's make one for imported models that may have mirrored normals if the original parts' transforms were applied during import. The simpleMeshMod modifier is both flexible and simple as you don't have to in any way handle the baseobject type or any modifiers below, and as Mesh as a type is very easy to reason about.
Here's a prototype that's for now using some placeholder types and methods:
plugin simpleMeshMod mirrorNormalsname:"Mirror Normals" category:"Examples"classID:#(0x7d6536c9, 0x74f97216)(parameters main rollout:params(flip type:#boolTab tabSize:3 ui:(chbXAxis, chbYAxis, chbZAxis))rollout params "Parameters"(checkButton chbXAxis "Flip X Axis" width:100checkButton chbYAxis "Flip Y Axis" width:100checkButton chbZAxis "Flip Z Axis" width:100)fn anythingToFlip =on modifyMesh do if flip[1] or flip[2] or flip[3] do(if mesh.explicitNormals.count <= 0 do return()local multipliers = [if flip[1] then -1 else 1, if flip[2] then -1 else 1, if flip[3] then -1 else 1]for normal in mesh.explicitNormals do normal *= multipliers))
Just for completeness sake, the next code sample is the boilerplate that we won't be using and which would be needed for the aforementioned on-the-fly compilation. It also muddles the logic a bit as you wouldn't want to be repeatedly converting the the mesh pointer to its .NET wrapper for each of the normals; so you either shift the looping logic into the function or you'd have to initialize the class with the current mesh – doesn't make that much of a difference here but it can get out of hand pretty fast and is one more thing you'd have to think about:
local libraries = #(getDir #maxRoot + "Autodesk.Max.dll")local source = "using System;using Autodesk.Max;class MeshNormals {public static void MutliplyNormals(IntPtr mesh_pointer, float[] multipliers) {...}}"fn compilationHelper source libraries className =(local netCompilationHelper = dotNetClass "CSharpUtilities.CSharpCompilationHelper"netCompilationHelper.compile source librariesdotNetClass className)fn codeDomCompiler source libraries className =(local compilerParams = dotNetObject "System.CodeDom.Compiler.CompilerParameters" librariescompilerParams.GenerateInMemory = truelocal compilerResults = (dotNetObject "Microsoft.CSharp.CSharpCodeProvider").CompileAssemblyFromSource compilerParams #(source)for err = 0 to compilerResults.errors.count - 1 do print (compilerResults.errors.item[err].ToString())compilerResults.CompiledAssembly.CreateInstance className)local meshNormals = (if (maxVersion())[8] >= 2026 then compilationHelper else codeDomCompiler) source libraries "MeshNormals"
I don't know about you but I'm happy to skip that and do a little coding instead 🤓
First of all, let's get the IMesh counterpart of the TriMesh value to get access to the explicit normals. There's one interface that's extremely useful and which we can also use here:
local iGlobal = (dotNetClass "Autodesk.Max.GlobalInterface").Instance
You might already be familiar with using it to bring all sorts of scene entities, be it nodes, controllers, paramBlocks or arbitrary ReferenceTargets:
iGlobal.Animatable.GetAnimByHandle (getHandleByAnim maxEntity)
Well, this won't work here as the TriMesh provided by the modifier is a Value and not an Animatable. But there's a way to marshal native objects to managed ones so all we need is to get the pointer to the Value. Maybe by (ab)using the dotNetMXSValue?
local wrapped = dotNetMXSValue meshlocal dotnetVal = dotNet.ValueToDotNetObject wrapped (dotNetClass "System.Object")dotnetVal.get_value() --> System.Reflection.Pointer
We could work with that in compiled code but that would a) beat the purpose of this post, and b) with .NET methods there are better ways, like using the special pointer argument naming that was introduced to make interop with MCG easier.
In MAXScript, there's no reasonably simple way of getting the underlying value of the returned System.Reflection.Pointer. But there's another feature introduced with MCG, and that's the MaxPlus types mirroring native max types. With those, we can easily wrap and unwrap it again:
((dotNetClass "Autodesk.Max.MaxPlus.Mesh")._CreateWrapper mesh).GetUnwrappedPtr()
The MaxPlus.Mesh is only used as an intermediary as it doesn't expose any of the properties we want to accept. As you could expect, there's also MaxPlus.Point3, MaxPlus.Matrix3, MaxPlus.Box3, MaxPlus.BitArray and so on, covering most of the types you might want to bring across. You can get a full list like this:
maxPlus = dotNet.loadAssembly "MaxPlusDotNet.dll"for type in maxPlus.GetExportedTypes() do(local found = falsefor method in type.GetMethods() while not found do found = method.Name == "_CreateWrapper"if found do format "Autodesk.Max.MaxPlus.%\n" type.Name)
Now the only missing link is getting the IMesh object from the IntPtr. There are two ways you can use, both do the same and it's up to which one you will use. For our mesh, the methods would be:
iMesh = iGlobal.Mesh.Marshal meshPtriMesh = ((dotNetClass "Autodesk.Max.Wrappers.CustomMarshalerMesh").GetInstance "").MarshalNativeToManaged meshPtr
In both cases, you can replace Mesh with Point3 or other type, and in both cases, if you want to use them more than once it makes sense to make a variable to alias the method since the repeated lookup would incurr quite a heavy penalty otherwise.
Let's see what the updated modifier code looks like now:
plugin simpleMeshMod mirrorNormalsname:"Mirror Normals" category:"Examples"classID:#(0x7d6536c9, 0x74f97216)(parameters main rollout:params(flip type:#boolTab tabSize:3 ui:(chbXAxis, chbYAxis, chbZAxis))rollout params "Parameters"(checkButton chbXAxis "Flip X Axis" width:100checkButton chbYAxis "Flip Y Axis" width:100checkButton chbZAxis "Flip Z Axis" width:100)local iGlobal = (dotNetClass "Autodesk.Max.GlobalInterface").Instanceon modifyMesh do if flip[1] or flip[2] or flip[3] do(local meshPtr = ((dotNetClass "Autodesk.Max.MaxPlus.Mesh")._CreateWrapper mesh).GetUnwrappedPtr()local normals = (iGlobal.Mesh.Marshal meshPtr).SpecifiedNormalsif normals == undefined or not normals.AnyExplicitNormalsSet do return()local multipliers = [if flip[1] then -1 else 1, if flip[2] then -1 else 1, if flip[3] then -1 else 1]for normal in normals.NormalArray do normal.MultiplyBy multipliers))
This won't work just yet. For one, we will have to replace the 'multipliers' Point3 value with a proper IPoint3 wrapper. That's quite easy as the global interface also provides a way to construct native max types:
local multipliers = iGlobal.Point3.Create (if flip[1] then -1 else 1) (if flip[2] then -1 else 1) (if flip[3] then -1 else 1)
Where it gets a bit less obvious is that the normals.NormalArray doesn't provide a proper array – instead, we get the first element of the array only (or rather an IPoint3 interface to the underlying native object it points to). That won't be an issue though because you can still iterate over the array manually – and there are lots of ways to do that depending on what you want to do with the values.
For example, if all we cared about was to flip all the normals, we could Marshal.Copy 3 * numNormals of floats to a temp array, flip all the signs, and copy everything back again. This works because in memory layout, Point3 is represented as three successive floats, and the array comprises of all the successive Point3s:
marshal = dotNetClass "System.Runtime.InteropServices.Marshal"floats = (dotNetClass "System.Array").CreateInstance (dotNet.getType "System.Single") (numNormals *3) asDotNetObject:onmarshal.Copy normalPtr floats 0 (numNormals * 3)mxsFloats = dotnet.dotNetObjectToValue floatsfor i = 1 to (numNormals * 3) do mxsFloats[i] *= -1 --> faster than setting the values of the .NET arraymarshal.Copy mxsFloats 0 normalPtr (numNormals * 3)
For our intended use case, it could be done in a similar manner when flipping just select axes, too, but both from the readability standpoint and ease of later translation to C++ (or at least a C# DLL), I'm okay with sacrificing the performance. Just bear in mind that you will pay the performance penalty both for accessing any property or method of a .NET entity in MAXScript, as well as the penalty for creating the managed .NET objects that wrap the native objects. When the plan is to create a huge number of such temporary objects, always double-check for other possible approaches.
Either way, in both cases we first need to get that pointer out of the IPoint3 wrapper. Max provides us with a handy property INativeObject__NativePointer — note that it used to be called just NativePointer before Max 2020, and even earlier it was called Handle (but that was before MCG and the handy MaxPlus types were introduced anyway).
Retrieving its value is straightforward:
normalPtr = normals.NormalArray.INativeObject__NativePointer
If you for some reason needed it in the form of the original IntPtr, you could prevent the implicit conversion through getProperty:
normalPtr = getProperty normals.NormalArray #INativeObject__NativePointer asDotNetObject:on
Alternatively, if you want to avoid version-specific switches, you can also get the pointer this way:
marshalerPoint3 = (dotNetClass "Autodesk.Max.Wrappers.CustomMarshalerPoint3").GetInstance ""ptr = marshalerPoint3.MarshalManagedToNative normals.NormalArray
And now we can increment the pointer we got by the size of Point3. In this case, it would be easy to calculate that yourself — but even if you had a sudden brain fog or felt extra lazy, a quick check with Visual Studio and the SDK will quickly provide the exact value. Hover over the type and voilà :

Same with checking the offsets, click the Memory Layout link and everything will be laid out in a nice table:

While hardcoding the value is quite a safe bet with basic types like Point3, don't rely on it – the size and offsets may change between SDK versions.
The last missing step is getting the IPoint3 that corresponds to the pointer, which is once again a case for marshalling:
normal = iGlobal.Point3.Marshal normalPtr
Putting it all together, it may look for example like this. I've opted for a do-while loop decrementing the number of remaining normals until there's none left to make it more obvious, but you can do the pointer arithmetic in a regular for loop instead, just as long as you get the math right:
plugin simpleMeshMod mirrorNormalsname:"Mirror Normals" category:"Examples"classID:#(0x7d6536c9, 0x74f97216)(parameters main rollout:params(flip type:#boolTab tabSize:3 ui:(chbXAxis, chbYAxis, chbZAxis))rollout params "Parameters"(checkButton chbXAxis "Flip X Axis" width:100checkButton chbYAxis "Flip Y Axis" width:100checkButton chbZAxis "Flip Z Axis" width:100)local iGlobal = (dotNetClass "Autodesk.Max.GlobalInterface").Instancelocal marshalPoint3 = iGlobal.Point3.Marshalon modifyMesh do if flip[1] or flip[2] or flip[3] do(local meshPtr = ((dotNetClass "Autodesk.Max.MaxPlus.Mesh")._CreateWrapper mesh).GetUnwrappedPtr()local normals = (iGlobal.Mesh.Marshal meshPtr).SpecifiedNormalsif mesh.numVerts < 3 or normals == undefined or not normals.AnyExplicitNormalsSet do return()local remainingNormals = normals.NumNormalslocal normalPtr = normals.NormalArray.INativeObject__NativePointerlocal multipliers = iGlobal.Point3.Create (if flip[1] then -1 else 1) (if flip[2] then -1 else 1) (if flip[3] then -1 else 1)do (local normal = marshalPoint3 normalPtrnormal.MultiplyBy multipliersnormalPtr += 12)while (remainingNormals -= 1) > 0))
It's a modifier and it does what we wanted it to do, mirrors explicit normals. Once all three axes are checked, the outcome is visually the same as just flipping the normals – however, unlike the Normal modifier, it doesn't flip faces, only the vertex normals change:

Although it will work for the imported Editable Mesh objects, the normals might be cleared in other cases. To prevent that, setting the MESH_NORMAL_MODIFIER_SUPPORT is needed – you can find the value under MaxPlus.Constants. And while getting the array of normals was instructive in showing how to work with arrays, there's a better of transforming multiple normals at once, and that's using the MeshNormalSpec.Transform method:
plugin simpleMeshMod mirrorNormalsname:"Mirror Normals" category:"Examples"classID:#(0x7d6536c9, 0x74f97216)(parameters main rollout:params(flip type:#boolTab tabSize:3 ui:(chbXAxis, chbYAxis, chbZAxis))rollout params "Parameters"(checkButton chbXAxis "Flip X Axis" width:100checkButton chbYAxis "Flip Y Axis" width:100checkButton chbZAxis "Flip Z Axis" width:100)local iGlobal = (dotNetClass "Autodesk.Max.GlobalInterface").Instancelocal normalSupport = (dotNetClass "Autodesk.Max.MaxPlus.Constants").MeshNormalModifierSupporton modifyMesh do if flip[1] or flip[2] or flip[3] do(local meshPtr = ((dotNetClass "Autodesk.Max.MaxPlus.Mesh")._CreateWrapper mesh).GetUnwrappedPtr()local normals = (iGlobal.Mesh.Marshal meshPtr).SpecifiedNormalsif mesh.numVerts < 3 or normals == undefined or not normals.AnyExplicitNormalsSet do return()local multipliers = iGlobal.Point3.Create (if flip[1] then -1 else 1) (if flip[2] then -1 else 1) (if flip[3] then -1 else 1)local tm = iGlobal.Matrix3.Create()tm.Scale multipliers offnormals.Transform tm off () onnormals.SetFlag normalSupport on))
That’s it! Together with the Python’s ctypes module and its windll interface, which provides access to system dlls like user32, this basically eliminated the need for on‑the‑fly compilation in my workflow. You may also find few more examples of code snippets using .NET in MAXScript among my public gists.
Hope you find inspiration and have fun!
ACKNOWLEDGEMENTS: Special thanks to Denis Trofimov for advice that made the code and the article better, and for all his contributions over the years that I could learn from in the first place.
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!
Leave a Comment