Grabbing The Material Preview

Dec 9, 2014 by     No Comments    Posted under: 3dsMax, DotNet, Maxscript, pipeline
That's not going to work...

That’s not going to work…

I’ve recently started to figure out a shader database for our lighting and rendering pipeline, so needed to think about how to implement this as a complete system. The core would be to provide an easy way of storing materials and associated metadata to enable easy searching for shaders based on certain keywords, like type or speed rating. With C#, its comparatively easy to construct such a system as you can serialise the data to XML and use some great search methods via Language Integrated Query, or LINQ. If you aren’t sure about what LINQ is capable of, the webulator is awash with great information about it, and it’s featured in a couple of my previous posts up until now.

So in planning a task like this, I would guess that secret of any good shader library interface would be the ability to provide a snapshot of the material thumbnail from the material editor. So if you’ve tried to do this in the past it is at best a hacky ball-ache, and at worst just not possible. The common approach, passed down from the wisdom of the elders of Maxscript is to render sphere in your current scene, setting up a camera and lights then restoring the scene back after this has completed. But with renderers like vray being used more and more, it’s a painful process. Not to mention it feels so tantalisingly close. I mean, its SITTING THERE IN FRONT OF YOU! JUST GIVE IT… ahem.

Given a fresh set of eyes with the benefit of distance from my previous attempts I think I’ve come up with a method that is intimately nothing more than a hacky ball-ache, but concludes that it is in fact possible. So what is it that is so unreachable about the material editor preview window? In past times,  you would have to see if there is maxscript access to a particular part of a UI. Now, with dotnet in maxscript we can pimp this procedure with some window hooks to redress the lack of maxscript access. My approach to a lot of automation in max is to break down and understand the procedure, so there are a few things that we need to be able to do with the material editor in order for this to work.

Switch to the compact (or old school) material editor and display it
Highlight to material we want
Double click the swatch to present the preview window
Get the window handle
Somehow capture the bitmap of the preview window
Save it

Simples!

So to do this, we’re going to need some lower level automation classes. This is where our old friend pInvoke comes in handy. There are some useful methods available to us if we can create a nice dotnet assembly of the fly with the methods that we want to use.

fn CreatePrintWindowOps forceRecompile:on = if forceRecompile do
(
source = "using System;
using System.Drawing;
using System.Runtime.InteropServices;
using System.Diagnostics;

class PrintWindowOps
{
[DllImport(\"user32.dll\")]
public static extern bool GetWindowRect(System.IntPtr hWnd, out RECT lpRect);
[DllImport(\"user32.dll\")]
public static extern bool GetClientRect(System.IntPtr hWnd, out RECT lpRect);
[DllImport(\"user32.dll\")]
public static extern bool PrintWindow(System.IntPtr hWnd, System.IntPtr hdcBlt, int nFlags);
[DllImport(\"user32.dll\")]
public static extern int SetCursorPos(Int32 x, Int32 y);

private const int MOUSEEVENT_LEFTDOWN = 0x0002;
private const int MOUSEEVENT_LEFTUP = 0x0004;

[DllImport(\"user32.dll\", CharSet = CharSet.Auto, CallingConvention = CallingConvention.StdCall)]
public static extern void mouse_event(int dwFlags , int dx , int dy, int cButtons, int dwExtraInfo);

[DllImport(\"user32.dll\")]
public static extern void MoveWindow(System.IntPtr hWnd, int x, int y, int w, int h, Boolean bRepaint);

public static void DoubleClick(int x, int y)
{
SetCursorPos(x, y);
System.Threading.Thread.Sleep(100);
mouse_event(MOUSEEVENT_LEFTDOWN, 0, 0, 0, 0);
mouse_event(MOUSEEVENT_LEFTUP, 0, 0, 0, 0);
mouse_event(MOUSEEVENT_LEFTDOWN, 0, 0, 0, 0);
mouse_event(MOUSEEVENT_LEFTUP, 0, 0, 0, 0);
}

public static Rectangle GetWindowRectangle(System.IntPtr hwnd)
{
RECT rc;
GetWindowRect(hwnd, out rc);
return new Rectangle(rc.Location.X, rc.Location.Y, rc.Width, rc.Height);
}

public static Rectangle GetClientRectangle(System.IntPtr hwnd)
{
RECT rc;
GetClientRect(hwnd, out rc);
return new Rectangle(rc.Location.X, rc.Location.Y, rc.Width, rc.Height);
}

public static Bitmap PrintWindow(System.IntPtr hwnd)
{
RECT rc;
GetWindowRect(hwnd, out rc);

Bitmap bmp = new Bitmap(rc.Width, rc.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb);
Graphics gfxBmp = Graphics.FromImage(bmp);
System.IntPtr hdcBitmap = gfxBmp.GetHdc();

PrintWindow(hwnd, hdcBitmap, 0);

gfxBmp.ReleaseHdc(hdcBitmap);
gfxBmp.Dispose();

return bmp;
}

[StructLayout(LayoutKind.Sequential)]
public struct RECT
{
private int _Left;
private int _Top;
private int _Right;
private int _Bottom;

public RECT(RECT Rectangle) : this(Rectangle.Left, Rectangle.Top, Rectangle.Right, Rectangle.Bottom)
{

}

public RECT(int Left, int Top, int Right, int Bottom)
{
_Left = Left;
_Top = Top;
_Right = Right;
_Bottom = Bottom;
}

public int X {
get { return _Left; }
set { _Left = value; }
}
public int Y {
get { return _Top; }
set { _Top = value; }
}
public int Left {
get { return _Left; }
set { _Left = value; }
}
public int Top {
get { return _Top; }
set { _Top = value; }
}
public int Right {
get { return _Right; }
set { _Right = value; }
}
public int Bottom {
get { return _Bottom; }
set { _Bottom = value; }
}
public int Height {
get { return _Bottom - _Top; }
set { _Bottom = value + _Top; }
}
public int Width {
get { return _Right - _Left; }
set { _Right = value + _Left; }
}
public Point Location {
get { return new Point(Left, Top); }
set {
_Left = value.X;
_Top = value.Y;
}
}
public Size Size {
get { return new Size(Width, Height); }
set {
_Right = value.Width + _Left;
_Bottom = value.Height + _Top;
}
}

public static implicit operator Rectangle(RECT Rectangle)
{
return new Rectangle(Rectangle.Left, Rectangle.Top, Rectangle.Width, Rectangle.Height);
}
public static implicit operator RECT(Rectangle Rectangle)
{
return new RECT(Rectangle.Left, Rectangle.Top, Rectangle.Right, Rectangle.Bottom);
}
public static bool operator ==(RECT Rectangle1, RECT Rectangle2)
{
return Rectangle1.Equals(Rectangle2);
}
public static bool operator !=(RECT Rectangle1, RECT Rectangle2)
{
return !Rectangle1.Equals(Rectangle2);
}

public override string ToString()
{
return \"{Left: \" + _Left + \"; \" + \"Top: \" + _Top + \"; Right: \" + _Right + \"; Bottom: \" + _Bottom + \"}\";
}

public override int GetHashCode()
{
return ToString().GetHashCode();
}

public bool Equals(RECT Rectangle)
{
return Rectangle.Left == _Left && Rectangle.Top == _Top && Rectangle.Right == _Right && Rectangle.Bottom == _Bottom;
}

public override bool Equals(object Object)
{
if (Object is RECT) {
return Equals((RECT)Object);
} else if (Object is Rectangle) {
return Equals(new RECT((Rectangle)Object));
}

return false;
}
}

}"

csharpProvider = dotnetobject "Microsoft.CSharp.CSharpCodeProvider"
compilerParams = dotnetobject "System.CodeDom.Compiler.CompilerParameters"
compilerParams.ReferencedAssemblies.Add "System.dll"
compilerParams.ReferencedAssemblies.Add "System.Drawing.dll"
--compilerParams.ReferencedAssemblies.Add "System.Diagnostics.dll"
compilerParams.ReferencedAssemblies.Add "System.Runtime.InteropServices.dll"

compilerParams.GenerateInMemory = true
compilerResults = csharpProvider.CompileAssemblyFromSource compilerParams #(source)

-- this is very useful to debug your source code and check for referencing errors
if (compilerResults.Errors.Count > 0 ) then
(
errs = stringstream ""
for i = 0 to (compilerResults.Errors.Count-1) do
(
err = compilerResults.Errors.Item[i]
format "Error:% Line:% Column:% %\n" err.ErrorNumber err.Line \
err.Column err.ErrorText to:errs
)
MessageBox (errs as string) title: "Errors encountered while compiling C# code"
return undefined
)

(compilerResults.CompiledAssembly).createInstance "PrintWindowOps"
)

The class we are going to use is mainly constructed of the following methods

GetWindowRect – Gets the boundaries of the current window from the supplied handle
GetClientRect – Gets the boundaries of the parent window from the supplied handle
MoveWindow – Reposition the window on screen
PrintWindow – Captures a bitmap of the current window from the supplied handle
SetCursorPos – Moves the mouse via code
DoubleClick – Clicks the mouse via code

See, I told you it was hacky. We’re going to do everything to bring up the material preview window via code, including moving the mouse and double clicking it. Its worth noting that in 3dsMax 2014, a few methods were added to the windows structure, namely get/setwidowpos. This would make some of the code from the custom assembly redundant, but since people might not be up to that version yet, I’ve kept it as broad as possible. The code is heavilly commented so you can see where you can replace any calls if you needed.

So once the Material Editor is open, we’ll use DialogMonitorOPs to register the window opening, return the correct handle and capture the bitmap. Why do we need DialogMonitorOPs? Oddly, the material editor is a strange beast. The preview panel inside the editor window appears to be parented to a different window dll than the 3dsmax.dll that all other Ui elements link to. Using sometihng like spy++ to look into the window handles yielded nothing as far as the material editor was concerned. But DialogMonitorOps did, and told us that the material editor is triggered by the res1.dll. I have found precisely ZERO information about this on any site, including the SDK docs so this is one of those things that I just had to wear rather than understand.

Although, since DialogMonitorOps reports some useful extra data, we can sandwich the actual code to discover the preview into the callback that we register. So the process is now…

Switch to the compact (or old school) material editor and display it
Highlight to material we want
Register a function via DialogMonitorOps
Double click the swatch to present the preview window
Get the window handle from the data provided via DialogMonitorOps
Capture the thumbnail
Unregister DialogMonitorOps Callback

There’s a perfect example of how to use DialogMonitorOps in the MXS Help. I used this to troubleshoot my method in order to find a solution.

Quirks of using this procedure

DialogMonitorOps gives us a window handle of the panel that contains the preview. The classname of the preview window is DragDropWindow, and we know that it is the correct object as we have only opened the preview window by code. However, calling PrintWindow on this handle gives an empty, black bitmap. I again wasn’t sure how to deal with this. However, calling PrintWindow on the client window gives us a capture of the bitmap. So all we need to do is work out the crop window of the child control and draw this region into a new bitmap to complete the preview.

dropPanelHwnd = for i in (UIAccessor.GetChildWindows WindowHandle) where (UIAccessor.GetWindowClassName i) == "DragDropWindow" do exit with i
dnDPH = (dotnetobject "System.IntPtr" dropPanelHwnd)

rect = cpo.GetWindowRectangle dnDPH
loc = cpo.GetClientRectangle dnDPH
-- get location data from the main preview panel
wRect = cpo.GetWindowRectangle wPtr
clientCropCoords = dotnetobject "system.drawing.rectangle" (rect.x-wrect.x) (rect.y-wrect.y) loc.width loc.height
mbmp = cpo.PrintWindow wPtr
cBmp = mbmp.clone clientCropCoords (dotnetclass "System.Drawing.Imaging.PixelFormat").DontCare

if not doesFileExist outputDir then makeDir outputDir
-- save a png file of the preview
bmpOutput = (pathconfig.appendPath outputDir (matName + ".png"))
cBmp.save bmpOutput

The full method

Here’s the full code to capture the thumbnail. The really useful thing is as we have the window handle, we can resize it off-screen to any size we like. Which means we can have high-res previews up to any size. Obviously, they are not perfect quality at higher resolutions, but it gives us a better size than the default material thumbnail.

filein "PrintWindowOpsFN.ms"
clearlistener()	
DialogMonitorOPS.unRegisterNotification id:#matPreviewOps  
  
/*
Max2014 has new windows structure functions that could replace printwindowops.GetWindowRectangle
I'm keeping it in my assembly though to keep a little backwards compatibility with earlier versions of max, but I've 
included the code for 2014+ for reference
*/

(
-- change here to alter the thumbnail size
local thumbsize = 300
local cpo = CreatePrintWindowOps()

fn grabMaterialPreview debug:false outputDir:@"C:/MatPreview/" =
(
	-- if we are at this point, it means that we know the material editor is open
	-- this has been called via DialogMonitorOPS
	-- we are pretty sure this will be the material preview window as we've just clicked in the drop panel of the material editor
	WindowHandle = DialogMonitorOPS.GetWindowHandle()	
	matName = meditMaterials[activeMeditSlot].name
	dllName = UIAccessor.GetWindowDllFileName WindowHandle

	-- check the dll name. Material Editor doesnt use #max as the window parent
	-- but a dll called res1.dll
	if dllName != undefined and filenamefromPath dllName == "res1.dll" then
	(			
	--this is the entire material window (including the update button etc..)
	wPtr = dotnetobject "System.IntPtr" WindowHandle			
	if debug then format "Dialog Window Handle: %\n" WindowHandle
		
	-- get the window positon data 
	cMEPpos = cpo.GetWindowRectangle wPtr	
	--get the current position of the dialog in max2014
	--cMEPpos = windows.getWindowPos WindowHandle	
				
	-- set the thumbnail size by code (calculated by size wanted plus 56)
	-- this also forces the window offscreen 
	format "ThumbSize %\n" thumbsize
	cpo.MoveWindow wPtr	-1000 -1000 (thumbsize+56) (thumbsize+56) true
	
	-- Max2014
	-- windows.setWindowPos WindowHandle -1000 -1000 (thumbsize+56) (thumbsize+56) true	
					
	-- get the preview window handle now we have the hwnd of the material preview	
	dropPanelHwnd =  for i in (UIAccessor.GetChildWindows WindowHandle) where (UIAccessor.GetWindowClassName i) == "DragDropWindow" do exit with i
	
	-- this is the material preview handle- 
	-- if you try to pass this handle to printWindow, it will be BLANK 
	-- so we work out the client are and crop the bitmap from the capture of the main window
	dnDPH = (dotnetobject "System.IntPtr" dropPanelHwnd)

	-- universal method via Pinvoke
	-- get location data from the material preview window
	rect = cpo.GetWindowRectangle dnDPH
	loc = cpo.GetClientRectangle dnDPH
	-- get location data from the main preview panel
	wRect = cpo.GetWindowRectangle wPtr
				
	--max2014 onwards
	--mxsWP_prev = windows.getWindowPos dropPanelHwnd
	--mxsWP_me = windows.getWindowPos WindowHandle
		
	--format "mxsWP_prev %\n" mxsWP_prev
	--format "mxsWP_me %\n" mxsWP_me
				
	format "rect % % % % %\n" rect rect.x rect.y rect.width rect.height
	format "wrect % % % % %\n" wrect wrect.x wrect.y rect.width rect.height
	format "loc % % % % %\n" loc loc.x loc.y loc.Width loc.height

	clientCropCoords = dotnetobject "system.drawing.rectangle" (rect.x-wrect.x) (rect.y-wrect.y) loc.width loc.height
	
	--windows.getWindowPos 2014+ method
	--clientCropCoords = dotnetobject "system.drawing.rectangle" (mxsWP_prev.x-mxsWP_me.x) (mxsWP_prev.y-mxsWP_me.y) mxsWP_prev.w mxsWP_prev.h

	mbmp = cpo.PrintWindow wPtr	
	cBmp = mbmp.clone clientCropCoords (dotnetclass "System.Drawing.Imaging.PixelFormat").DontCare

	if not doesFileExist outputDir then makeDir outputDir
	-- save a png file of the preview
	bmpOutput = (pathconfig.appendPath outputDir (matName + ".png"))	
	cBmp.save bmpOutput

	
	gc()
		
	--put the preview dialog back on screen	
	--windows.setWindowPos WindowHandle cMEPpos.x cMEPpos.y cMEPpos.w cMEPpos.h true	
			
	UIAccessor.CloseDialog WindowHandle
	
	DialogMonitorOPS.Enabled = false
	DialogMonitorOPS.unRegisterNotification id:#matPreviewOps
	-- if you want a post preview, uncomment this line
	--display (openBitmap bmpOutput)
	-- or a confirmation message
	--messagebox "Material Preview captured sucessfully!" title:"Material Capture"
	true
	)
	else
	(
		--format "Bypass Handle: %\n" WindowHandle
		false
	)	
) -- end grabMaterialPreview

fn captureCurrentMeditMaterial mtlSlot:activeMeditSlot = 
(
	wasSlate = false
	goCapture = true
		
	if MatEditor.mode == #advanced then
	(
		if querybox "Material Capture currently works on the compact material editor. Do you want to switch?" title:"Material Capture" then
		(
			wasSlate = true
			MatEditor.mode = #basic		
		)
		else goCapture = false
	)

	if goCapture then
	(
	-- open the material editor and get the window handle
	if not (MatEditor.isOpen()) then ( MatEditor.Open() )  
	matEditHwnd  = for w in (UIAccessor.GetPopupDialogs()) where matchpattern (UIAccessor.GetWindowText w) pattern:"Material Editor*" collect w 
		
	if matEditHwnd.count == 1 then 
	(
		if mtlSlot != undefined then
			if mtlslot>=1 and mtlSlot<= 24 then activeMeditSlot = mtlSlot
			
		dropPanel = for m in (windows.getChildrenHWND matEditHwnd[1]) where (matchpattern m[4] pattern:"DragDropWindow") do exit with m
		
		if isKindof dropPanel array then
		(
			-- need to cast the mxs hwnd pointer into a dotnet IntPtr,
			-- otherwise max internally casts to the wrong type
			
			dpHwnd = dotnetobject "System.IntPtr" dropPanel[1]
			dpp = cpo.GetWindowRectangle dpHwnd
			
			-- form max2014 onwards, you could use this to replace cpo.GetWindowRectangle
			-- dppMXS = windows.getWindowPos dropPanel[1]
			
			-- set the thumbsize (this is used in the grabMaterialPreview function)
			-- I'd pass it in the function call itself, but im not sure how to do this in DialogMonitorOPS.RegisterNotification
			-- thumbsize = 700
			
			-- enable DialogMonitorOPS to register the capture function
			DialogMonitorOPS.RegisterNotification grabMaterialPreview id:#matPreviewOps
			DialogMonitorOPS.Enabled = true
			--DialogMonitorOPS.ShowNotification()
			
			-- push any updates (to update UI on batch capture operations)
			windows.processPostedMessages()	
			
			-- Hackylicious! double click the panel to force the preview window to open
			-- we trigger the click in the center of the MEdit dialog is case the user moves the mouse during capture
			
			cpo.doubleclick (dpp.x + (dpp.width/2)) (dpp.y + (dpp.height/2))
				
			-- the same call in max2014 onwards	using the mxs hwnd		
			-- cpo.doubleclick (dppMXS.x + (dppMXS.w/2)) (dppMXS.y + (dppMXS.h/2))
		)	
		
	)
	-- if you want to revert the editor state back to slate, uncomment this line
	--if wasSlate then MatEditor.mode = #advanced 
	)
) -- end captureCurrentMeditMaterial

)

I have the C# assembly code in a separate file, and call it with a filein at the start of the main function.

Some testing code

 

-- create some random materials in the material editor...
for i = 1 to meditmaterials.count do meditmaterials[i] = standard diffuse:(color (random 0 255) (random 0 255) (random 0 255)) name:("Standard_" + formattedprint i format:"03i")

matPrevME

-- grab the entire material editor in one go...
for i = 1 to meditmaterials.count do captureCurrentMeditMaterial mtlSlot:i

To capture the current material in the material editor, just call

captureCurrentMeditMaterial()

Result! A folder full of material previews to be used in any number of ways!

matPrevThumbs

404
Centurian Wildlife Services