How To ... Develop a Bitmap Painting Tool - Unwrap UV Coordinates

In this step of the Bitmap Painting tool development, we will add the option to generate a graphical representation of the UV coordinates of an object inside the painting tool and assign automatically as diffuse map to the scene object.

NATURAL LANGUAGE

We will add a local variable to store the mesh object to work with.

We will add a new filter function to select only mesh objects.

We will add a new pickbutton to the UI to select an object to work with which will use the filter function.

We will add an option to the Edit menu to acquire the texture coordinates.

We will add a new function to read the UV coordinates from a selected mesh and draw strokes in the painting based on the vertex positions in texture space.

We will define a default temp. file name for the painting and will optionally save it after each stroke.

We will assign the temp. disk copy of the bitmap to the diffuse channel of the picked object.

We will add a new checkbox to the User Interface to enable and disable autosaving. When enabled, the bitmap will update in realtime as we paint in our tool.

SCRIPT:

   macroScript MicroPaint category: "HowTo"
   (
   global MicroPaint_CanvasRollout
   try (destroyDialog MicroPaint_CanvasRollout) catch()
   local isErasing = isDrawing = false
   local bitmapX = bitmapY = 512
   local bitmapx_1 = bitmapx-1
   local bitmapy_1 = bitmapy-1
   local temp_bitmap_filename = (getDir #preview +"/microPaint_temp.bmp")
   local theCanvasBitmap = bitmap bitmapX bitmapY color:white filename:temp_bitmap_filename
   local theBackgroundBitmap = bitmap bitmapX bitmapY color:white
   local currentPos = lastPos = [0,0]

   local theChannel = 1
   local theObj = undefined

   rcMenu CanvasMenu
   (
     subMenu "File"
     (
       menuItem new_menu "New"
       menuItem open_menu "Open..."
       menuItem save_as "Save As..."
       separator file_menu_1
       menuItem quit_tool "Quit"  
     )
     subMenu "Edit"
     (
       menuItem commit_menu "Commit Changes"
       separator edit_menu_1
       menuItem uv_menu "Get UV Coordinates..."
     )
     on commit_menu picked do copy theCanvasBitmap theBackgroundBitmap
     on uv_menu picked do MicroPaint_CanvasRollout.unwrapTexture()

     subMenu "Help"
     (
       menuItem about_tool "About MicroPaint..." 
     )

     on new_menu picked do
     (
       theBackgroundBitmap = theCanvasBitmap = bitmap bitmapX bitmapY color:MicroPaint_CanvasRollout.paperColor.color filename:temp_bitmap_filename
       MicroPaint_CanvasRollout.theCanvas.bitmap = theCanvasBitmap
     )

     on open_menu picked do
     (
       theOpenBitmap= selectBitmap()
       if theOpenBitmap != undefined do
       (
         copy theOpenBitmap theCanvasBitmap
         copy theOpenBitmap theBackgroundBitmap
         close theOpenBitmap
         MicroPaint_CanvasRollout.theCanvas.bitmap = theCanvasBitmap
       )
     )

     on save_as picked do
     (
       theSaveName = getSaveFileName types:"BMP (*.bmp)|*.bmp|Targa (*.tga)|*.tga|JPEG (*.jpg)|*.jpg"
       if theSaveName != undefined do
       (
         theCanvasBitmap.filename = theSaveName
         save theCanvasBitmap
         theCanvasBitmap.filename = temp_bitmap_filename
       )
     )
     on about_tool picked do messagebox "MicroPaint\nMAXScript Tutorial" title:"About..."
     on quit_tool picked do destroyDialog MicroPaint_CanvasRollout
   )

   fn mesh_filter obj = superclassof obj == GeometryClass and classof obj != TargetObject

   rollout MicroPaint_CanvasRollout "MicroPaint"
   (
     bitmap theCanvas pos:[0,0] width:bitmapX height:bitmapY bitmap:theCanvasBitmap
     colorpicker inkColor height:16 modal:false color:black across:6
     colorpicker paperColor height:16 modal:false color:white
     checkbutton autoSave "AutoSave" width:70
     checkbutton airBrush "AirBrush" width:70
     spinner AirBrushSpeed "Speed" range:[0.1,50,10] fieldwidth:30
     spinner BrushSize "Size" range:[1,50,1] type:#integer fieldwidth:40
     listbox BrushShape items:#("Circle","Box","Circle Smooth") pos:[bitmapX+5,0] width:90


     --NEW PICK BUTTON AND HANDLER:
     pickbutton pickMesh "Pick Mesh" width:90 height:30 pos:[bitmapX+5,140] filter:mesh_filter autodisplay:true

     on pickMesh picked obj do
     (
       if obj != undefined do
       (
         theObj = Obj
         try
         (
           copy theObj.material.diffusemap.bitmap theCanvasBitmap
           copy theObj.material.diffusemap.bitmap theBackgroundBitmap
           theCanvas.bitmap = theCanvasBitmap
         )catch() 
       )
     )
     --END NEW PICK BUTTON AND HANDLER

     fn paintBrush pos =
     (
       if isErasing then
         thePaintColor = (getPixels theBackgroundBitmap pos 1)[1]
       else
         thePaintColor = inkColor.color
       if thePaintColor == undefined then thePaintColor = white

       case BrushShape.selection of
       (
         1: (
           if distance pos currentPos <= BrushSize.value/2 do
             setPixels theCanvasBitmap pos #(thePaintColor)
           )
         2: setPixels theCanvasBitmap pos #(thePaintColor)
         3: (
           theFactor = (distance pos currentPos) / (BrushSize.value/2.0)
           if theFactor <= 1.0 do
           (
             theFactor = sin ( 90.0 * theFactor)
             thePixels = getPixels theCanvasBitmap pos 1
             if thePixels[1] != undefined do
             (
               thePixels[1] = (thePixels[1] * theFactor) + (thePaintColor * (1.0 - theFactor))
               setPixels theCanvasBitmap pos thePixels
             )
           )
         )--end case 3
       )--end case
     )--end fn

     fn drawStroke lastPos pos drawIt: =
     (
       currentPos = lastPos
       deltaX = pos.x - lastPos.x
       deltaY = pos.y - lastPos.y
       maxSteps = amax #(abs(deltaX),abs(deltaY))
       deltaStepX = deltaX / maxSteps
       deltaStepY = deltaY / maxSteps
       for i = 0 to maxSteps do
       (
         if airBrush.checked then
           for b = 1 to (BrushSize.value / AirBrushSpeed.value) do
             paintBrush (currentPos + (random [-BrushSize.value/2,-BrushSize.value/2] [BrushSize.value/2,BrushSize.value/2] ))
         else
           for b = -BrushSize.value/2 to BrushSize.value/2 do
             for c = -BrushSize.value/2 to BrushSize.value/2 do
               paintBrush (currentPos + [c,b])
         currentPos += [deltaStepX, deltaStepY]
       )
       if drawIt== true or drawIt == unsupplied do theCanvas.bitmap = theCanvasBitmap
     )


     --NEW UNWRAP TEXTURE FUNCTION STARTS HERE
     fn unwrapTexture =
     (
       if theObj != undefined then
       (
         theMesh = snapshotAsMesh theObj
         if meshop.getMapSupport theMesh theChannel do
         (
           faceCount = meshop.getNumMapFaces theMesh theChannel
           for f = 1 to faceCount do
           (
             theFace = meshop.getMapFace theMesh theChannel f
             vert1= meshop.getMapVert theMesh theChannel theFace.x
             vert2= meshop.getMapVert theMesh theChannel theFace.y
             vert3= meshop.getMapVert theMesh theChannel theFace.z
             drawStroke [vert1.x * bitmapx_1, bitmapy_1 - vert1.y * bitmapy_1] [vert2.x * bitmapx_1, bitmapy_1 - vert2.y * bitmapy_1] drawIt:false
             drawStroke [vert1.x * bitmapx_1, bitmapy_1 - vert1.y * bitmapy_1] [vert3.x * bitmapx_1, bitmapy_1 - vert3.y * bitmapy_1] drawIt:false
             drawStroke [vert3.x * bitmapx_1, bitmapy_1 - vert3.y * bitmapy_1] [vert2.x * bitmapx, bitmapy_1 - vert2.y * bitmapy_1] drawIt:false
           )
         ) 
         theCanvas.bitmap = theCanvasBitmap
         save theCanvasBitmap
         if theObj.material == undefined do theObj.material = Standard()
         if theObj.material.diffusemap == undefined do
           theObj.material.diffusemap = bitmapTexture filename:temp_bitmap_filename
         showTextureMap theObj.material true
         autoSave.checked = true
       )
     )
     --NEW UNWRAP TEXTURE FUNCTION ENDS HERE

     on autoSave changed state do if state do save theCanvasBitmap

     on MicroPaint_CanvasRollout lbuttondown pos do
     (
       lastPos = pos
       isDrawing = true
       isErasing = false
       drawStroke lastPos pos
     )
     on MicroPaint_CanvasRollout rbuttondown pos do
     (
       lastPos = pos
       isErasing = isDrawing = true
       drawStroke lastPos pos
     )
     on MicroPaint_CanvasRollout lbuttonup pos do
     (
       isErasing = isDrawing = false
       if autoSave.checked do save theCanvasBitmap
     )
     on MicroPaint_CanvasRollout rbuttonup pos do
     (
       isErasing = isDrawing = false
       if autoSave.checked do save theCanvasBitmap
     )

     on MicroPaint_CanvasRollout mousemove pos do
     (
       if isDrawing do drawStroke lastPos pos
       lastPos = pos
     )
   )
   createDialog MicroPaint_CanvasRollout (bitmapx+100) (bitmapy+30) menu:CanvasMenu
   MicroPaint_CanvasRollout.theCanvas.bitmap = theBackgroundBitmap
   )

Step-By-Step

--Code in italic has no changes since the previous version. macroScript MicroPaint category:"HowTo"
(
global MicroPaint_CanvasRollout
try(destroyDialog MicroPaint_CanvasRollout)catch()
local isErasing = isDrawing = false
local bitmapX = bitmapY = 512

local bitmapx_1 = bitmapx-1
local bitmapy_1 = bitmapy-1

To calculate the position of the UV coordinates inside the bitmap, we have to count the pixels as the size of the bitmap minus one (for example, 0 to 511 and not 1 to 512). To make out life easier, we define two variables to store these corrected values.

local temp_bitmap_filename = (getDir #preview +"/microPaint_temp.tga")
local theCanvasBitmap = bitmap bitmapX bitmapY color:white filename:temp_bitmap_filename

To be able to save the current painting to a temporary file on disk, we have to define a file name and assign it to the bitmap. The file name is stored in a local variable that will be used in other parts of the script to access it. TGA appears to be faster to save than BMP, but you can save to any format you want.

local theBackgroundBitmap = bitmap bitmapX bitmapY color:white
local currentPos = lastPos = [0,0] localtheChannel = 1

This is the map channel to read from. You can add a User Interface spinner to control its value. For now, we will work with channel 1 only.

local theObj = undefined

This variable will store the mesh object to access the UV coordinates from.

rcMenu CanvasMenu
(
subMenu "File"
(
menuItem new_menu "New"
menuItem open_menu "Open..."
menuItem save_as "Save As..."
separator file_menu_1
menuItem quit_tool "Quit"  
)
subMenu "Edit"
(
menuItem commit_menu "Commit Changes"

separator edit_menu_1
menuItem uv_menu "Get UV Coordinates..."
)

We add a new menu item that will be used to acquire texture coordinates from a scene object.

on commit_menu picked do copy theCanvasBitmap theBackgroundBitmap on uv_menu picked do MicroPaint_CanvasRollout.unwrapTexture()

If the user picked the menu item, we call a new function that will be defined inside the rollout (see below).

subMenu "Help"
(
menuItem about_tool "About MicroPaint..." 
)

on new_menu picked do
(
theBackgroundBitmap = theCanvasBitmap = bitmap bitmapX bitmapY color:MicroPaint_CanvasRollout.paperColor.color filename:temp_bitmap_filename
MicroPaint_CanvasRollout.theCanvas.bitmap = theCanvasBitmap
)

on open_menu picked do
(
theOpenBitmap= selectBitmap()
if theOpenBitmap != undefined do
(
copy theOpenBitmap theCanvasBitmap
copy theOpenBitmap theBackgroundBitmap
close theOpenBitmap
MicroPaint_CanvasRollout.theCanvas.bitmap = theCanvasBitmap
)
)

on save_as picked do
(
theSaveName = getSaveFileName types:"BMP (*.bmp)|*.bmp|Targa (*.tga)|*.tga|JPEG (*.jpg)|*.jpg"
if theSaveName != undefined do
(
theCanvasBitmap.filename = theSaveName
save theCanvasBitmap theCanvasBitmap.filename = temp_bitmap_filename

After saving to a new file name, set back the filename to the temporary location, otherwise autosaving starts writing into the new file and we do not want this to happen.

)
)
on about_tool picked do messagebox "MicroPaint\nMAXScript Tutorial" title:"About..."
on quit_tool picked do destroyDialog MicroPaint_CanvasRollout
)

fn mesh_filter obj = superclassof obj == GeometryClass and classof obj != TargetObject

This function allows us to pick only geometry objects except target objects. It will be used as a filter function by a new pickbutton User Interface control.

rollout MicroPaint_CanvasRollout "MicroPaint"
(
bitmap theCanvas pos:[0,0] width:bitmapX height:bitmapY bitmap:theCanvasBitmap

colorpicker inkColor height:16 modal:false color:black across:6

To accommodate the new autoSave option, we increase the across parameter to 6.

colorpicker paperColor height:16 modal:false color:white

checkbutton autoSave "AutoSave" width:70

This new checkbutton will control whether the painting is constantly saved to disk after each stroke.

checkbutton airBrush "AirBrush" width:70
spinner AirBrushSpeed "Speed" range:[0.1,50,10] fieldwidth:30 spinner BrushSize "Size" range:[1,50,1] type:#integer fieldwidth:40

The default brush size is now 1. This is to avoid drawing a texture unwrapping with a thickness of 10 that can be very slow.

listbox BrushShape items:#("Circle", "Box", "Circle Smooth") pos:[bitmapX+5,0] width:90

pickbutton pickMesh "Pick Mesh" width:90 height:30 pos:[bitmapX+5,140] filter:mesh_filter autodisplay:true

This pickbutton will be used to select the scene object to unwrap the texture coordinates. It uses the new optional keyword autoDisplay added to 3ds Max 7. When set to true, it will display the name of the picked object on the button automatically. In 3ds Max 6 and earlier, the option will be ignored, but the script will still function correctly.

Pickbutton

on pickMesh picked obj do
(

This is the event handler of the pickbutton. If the user picked an object, it will be passed to the handler in the obj variable.

if obj != undefined do (

If the obj variable contains a valid object (the user did not cancel the picking) then,

theObj = Obj

we first assign the picked object to our new variable that will store the pick during the painting session.

try
(
copy theObj.material.diffusemap.bitmap theCanvasBitmap
copy theObj.material.diffusemap.bitmap theBackgroundBitmap
theCanvas.bitmap = theCanvasBitmap

Then, we try to read the diffusemap bitmap of the object and copy it into the canvas and background bitmaps of the painting tool, and assign it to the UI.

If there is no bitmap there, the error will be trapped and nothing will happen.

)catch() 
)
)

fn paintBrush pos =
(
if isErasing then
thePaintColor = (getPixels theBackgroundBitmap pos 1)[1]
else
thePaintColor = inkColor.color
if thePaintColor == undefined then thePaintColor = white

case BrushShape.selection of
(
1: (
if distance pos currentPos <= BrushSize.value/2 do
setPixels theCanvasBitmap pos #(thePaintColor)
)
2: setPixels theCanvasBitmap pos #(thePaintColor)
3: (
theFactor = (distance pos currentPos) / (BrushSize.value/2.0)
if theFactor <= 1.0 do
(
theFactor = sin ( 90.0 * theFactor)
thePixels = getPixels theCanvasBitmap pos 1
if thePixels[1] != undefined do
(
thePixels[1] = (thePixels[1] * theFactor) + (thePaintColor * (1.0 - theFactor))
setPixels theCanvasBitmap pos thePixels
)
)
)--end case 3
)--end case
)--end fn
  fn drawStroke lastPos pos drawIt: =
(

We add a new optional keyword to the drawStroke function. If it is set to false, the stroke will be drawn, but the bitmap will not be updated in the rollout. If it is true or not supplied, the bitmap will be updated after the stroke. This is to allow for faster drawing of the unwrapped texture without updating the view after each texture face.

currentPos = lastPos
deltaX = pos.x - lastPos.x
deltaY = pos.y - lastPos.y
maxSteps = amax #(abs(deltaX),abs(deltaY))
deltaStepX = deltaX / maxSteps
deltaStepY = deltaY / maxSteps
for i = 0 to maxSteps do
(
if airBrush.checked then
for b = 1 to (BrushSize.value / AirBrushSpeed.value) do
paintBrush (currentPos + (random [-BrushSize.value/2,-BrushSize.value/2] [BrushSize.value/2,BrushSize.value/2] ))
else
for b = -BrushSize.value/2 to BrushSize.value/2 do
for c = -BrushSize.value/2 to BrushSize.value/2 do
paintBrush (currentPos + [c,b])
currentPos += [deltaStepX, deltaStepY]
) if drawIt== true or drawIt == unsupplied do theCanvas.bitmap = theCanvasBitmap

If the new switch is set to true or not set at all, we update the display as before, otherwise the update is skipped.

)

fn unwrapTexture =
(

This is the new function that performs the selection of an object, the unwrapping of the texture coordinates, and the material/texture assignment.

if theObj != undefined then
(

If the user clicked a valid object,

theMesh =snapshotAsMeshtheObj

we extract the mesh from the top of its stack.

if meshop.getMapSupport theMesh theChannel do
(

If the selected object supports the specified channel,

faceCount = meshop.getNumMapFaces theMesh theChannel

we read the number of texture faces in that channel,

for f = 1 to faceCount do
(

and repeat the following code for every face found in the mesh:

theFace = meshop.getMapFace theMesh theChannel f

This is the map face definition. It returns a Point3 value where .x, .y, and .z are indices pointing at the texture vertices the face is using.

vert1= meshop.getMapVert theMesh theChannel theFace.x
vert2= meshop.getMapVert theMesh theChannel theFace.y
vert3= meshop.getMapVert theMesh theChannel theFace.z

Using this data, we read the three vertices of the face. Each one is a Point3 value containing the actual U, V, and W values. We will draw only the UV and discard the W.

drawStroke [vert1.x * bitmapx_1, bitmapy_1- vert1.y * bitmapy_1] [vert2.x * bitmapx_1, bitmapy_1- vert2.y * bitmapy_1] drawIt:false

We call the drawStroke function using the first and second texture vertex coordinates multiplied by the size of the bitmap. The flag drawIt is set to false to speed up the generation of the image. If you change it to true, you can watch the function drawing interactively in the image, but this can take a very long time.

The calculation of the coordinates is based on the fact that the lower left corner of the UV space has coordinates 0,0 and the upper right has coordinates 1,1.

The bitmap on the other hand has its upper left corner as 0,0 and its lower right corner as bitmapx-1, bitmapy-1 (511,511 in our example).

So, to convert the UV coordinates in pixel coordinates, we multiply the width resp. height of the bitmap by the U resp. V value, and turn the vertical result upside-down by subtracting from the height.

drawStroke [vert1.x * bitmapx_1, bitmapy_1- vert1.y * bitmapy_1] [vert3.x * bitmapx_1, bitmapy_1- vert3.y * bitmapy_1] drawIt:false
drawStroke [vert3.x * bitmapx_1, bitmapy_1- vert3.y * bitmapy_1] [vert2.x * bitmapx_1, bitmapy_1- vert2.y * bitmapy_1] drawIt:false

We call the drawStroke function for the other two pairs of vertices, thus drawing all edges of the face.

)
) 
theCanvas.bitmap = theCanvasBitmap

Now, we can update the rollout bitmap to show the result.

save theCanvasBitmap

We can also save the bitmap to its temporary location.

if theObj.material == undefined do theObj.material = Standard()

If the selected object has no material, we assign a new material.

try
(if theObj.material.diffusemap == undefined do
theObj.material.diffusemap = bitmapTexture filename:temp_bitmap_filename

If the selected object's diffusemap is not defined yet, we try to assign a new texture map pointing at the temporary copy of our painting. If the object does not have a standard material assigned, trying to access the diffusemap channel will cause an error, which will be prevented by the try()catch() context.

showTextureMap theObj.material true

We press the Show Map In Viewport of the material at object level. If the texture assignment is successful, the bitmap must be shown in the viewport.

autoSave.checked = true

If everything went ok, we can also turn on the new autosave option to force the bitmap to update the selected object as you paint.

)catch()
)
)

on autoSave changed state do if state do save theCanvasBitmap

If the user checked the new checkbutton, the bitmap will be saved to its temporary location immediately.

on MicroPaint_CanvasRollout lbuttondown pos do
(
lastPos = pos
isDrawing = true
isErasing = false
drawStroke lastPos pos
)
on MicroPaint_CanvasRollout rbuttondown pos do
(
lastPos = pos
isErasing = isDrawing = true
drawStroke lastPos pos
)
on MicroPaint_CanvasRollout lbuttonup pos do
(
isErasing = isDrawing = false if autoSave.checked do save theCanvasBitmap 

If the new checkbutton is pressed, the bitmap will be saved to its temporary location after each stroke.

) 
on MicroPaint_CanvasRollout rbuttonup pos do
(
isErasing = isDrawing = false

if autoSave.checked do save theCanvasBitmap

If the new checkbutton is pressed, the bitmap will be saved to its temporary location after each erase stroke.

 ) 

on MicroPaint_CanvasRollout mousemove pos do
(
if isDrawing do drawStroke lastPos pos
lastPos = pos
)
)
createDialog MicroPaint_CanvasRollout (bitmapx+100) (bitmapy+30) menu:CanvasMenu
MicroPaint_CanvasRollout.theCanvas.bitmap = theBackgroundBitmap
) 

RESULT:

In this example, Unwrap UVW modifier is applied to a teapot and the Flatten function is used to create the new texture coordinates.

Then, the object is selected in MicroPaint and the texture coordinates are drawn using the Circular Smooth brush with size 5, Airbrush turned on, and Airbrush Speed set to 1.0.

This is the teapot as seen in the viewport:

Now, you can start painting on top of the UV coordinates using all available brushes - as long as the AutoSave button is checked, all your strokes must update in the viewports:

Previous Tutorial:

How To ... Develop a Bitmap Painting Tool - Erase Changes

Next Tutorial:

How To ... Develop a Bitmap Painting Tool - 3D Painting