Finding non-coplanar polygons (a python case study in custom filters)

Applies to: XSI 6.5

This article a case study in developing a custom filter that can be used to find non-coplanar polygons in a polygon mesh.

There are some tricks and trick to avoiding pit falls that you will stumble upon along the way, these include:

  • debugging custom filters
  • implementing filter options via global variables
  • gotcha with SIFilter
  • fixing dispatch errors
  • finding the triangle normals for a specific polygon
Table of contents

Debugging custom filters

Firstly, the filters don't take to kind to being debugged when active so to avoid unexpected exits I implemented a command that accesses the filter code subset callback and used that for the duration of debugging. If you want to test the polygon filter but find you need to edit the code and the filter is active deactivate the filter first. You do this by selecting another filter; your filter must not be active while the script gets reload or else you get hosed!

Application.SelectObj( Application.GetNonCoplanarPolygons( Application.Selection )) 

Inorder to call the filter subset callback from the the command callback I needed a filter context. Since I cannot get one from XSI I created one using python's class definition:

class _FilterContext(object):
	Input = []
	Output = [] 
	def GetAttribute(self, attrib):
		if attrib == "Input" : return self.Input
		if attrib == "Output" : return self.Output
		return null
		
	def SetAttribute(self, attrib, value):
		if attrib == "Input" : self.Input = value 
		if attrib == "Output" : self.Output = value
		return true

This allowed me to call the filter code. This is a handy trick for debugging all kinds of plugin not just filters.

	ctx = _FilterContext()
	ctx.SetAttribute( "Input", in_objects )
	
	NonCoplanar_Subset(ctx)
	
	colSubset = ctx.GetAttribute("Output")

Implementing filter options

The original version of this filter found all non-coplanar polygons but with a mesh coming from Zbrush this returned practically everything so I wanted refine the search to the specific types of polygons defined by the dot product between the triangle normals namely:

  • Acute ( < 90 )
  • Perpendicular ( = 90 )
  • Obtuse ( > 90 )

I could choose to implement 3 different filters or a single non-coplanar filter with some options; this example is about the later.

Filter don't support options but if you can create a custom property with a dialog and then get the gather data across to the filter callbacks you have implemented the same thing. Filter at registration time don't support user data so you need to share some global variables.

Plugins defined in the same script file can share global variables as long as the plugin is cached by XSI's plugin manager (see the context menu on the plugin item in the tree view of the plugin manager). If the plugin is not cached the script engine unloads the plugin each time it completes calling a callback.

The first time I hooked this up I wasn't able to access the global variable value. It never seemed to be set. I thought it might be the script engine caching or maybe the filters weren't loaded in the same engine space as the commands. It was something far simpler; I had forgot the global keyword.

WRONG

gMask = 0

def set_mask():
	if gMask:
		gMask=1
	else:
		gMask=1
    
def print_mask():
    Application.Logmessage( "mask = " + str(gMask) )

set_mask()
print_mask()
# ERROR : Traceback (most recent call last):
#   File "<Script Block >", line 13, in ?
#     set_mask()
#   File "<Script Block >", line 5, in set_mask
#     if gMask:
# UnboundLocalError: local variable 'gMask' referenced before assignment
#  - [line 5]

RIGHT

gMask = 0

def set_mask():
	global gMask
	if gMask:
		gMask=1
	else:
		gMask=1
    
def print_mask():
    Application.Logmessage( "mask = " + str(gMask) )

set_mask()
print_mask()

The upshot of this simple mistake was some head scratching and a nice set/set method around the globals for good measure.

Gotcha with SIFilter() command

You can use the SIFilter() command to call your custom filter but you should note is matches (NonCoplanar_Match callback) the items that you give it rather than returning you the filtered subset (NonCoplanar_Subset callback). For example if you have try to filter the polygons 88 and 89 for obtuse non-coplanar polygons but only polygon 89 is obtuse the SIFilter() command will return you an empty list.

Application.SIFilter("Character_Geometry.Jacket.poly[88,89]","NonCoplanar")

There's no way of calling you're subset callback outside of selection filters so if you want to expose this functionality you'll need to do it yourself, as I did with the GetNonCoplanarPolygons() command.

Fixing dispatch errors

99.9% Most of the time you don't need to worry about what a dispatch error means but then you hit the 0.1% when for some reason the object you have doesn't respond to the methods it should implement. You'll get an error something like the following:

# this snippet tries to go from a single polygon to a SubComponent 
# object containing all the polygons in the geometry
app = Application
app.NewScene("", 0)
app.CreatePrim("Grid", "MeshSurface", "", "")
app.SelectGeometryComponents("grid.poly[36]")
for item in app.Selection:
	cls = item.SubComponent.Parent3DObject.ActivePrimitive.Geometry.Polygons.SubComponent
	app.Logmessage( cls )
# ERROR : Traceback (most recent call last):
#   File "<Script Block >", line 6, in ?
#     cls = item.SubComponent.Parent3DObject.ActivePrimitive.Geometry.Polygons.SubComponent
#   File "C:\Python23\lib\site-packages\win32com\client\__init__.py", line 450, in __getattr__
#     raise AttributeError, "'%s' object has no attribute '%s'" % (repr(self), attr)
# AttributeError: '<win32com.gen_py.Softimage|XSI Object Model Library v1.5.Geometry instance at 0x195135528>' object has no attribute 'Polygons'
#  - [line 450]

You know you've hit a dispatch error because of the tell tail 'Attribute Error' complaining about the object Geometry not implementing the Polygons property; which you know it must 'cos it says so in the documentation.

The solution is to clobber pythons type casting mechanism with the following function and report the error to Softimage:

def dispFix( badDispatch ):
	import win32com.client.dynamic
	# Re-Wraps a bad dispatch into a working one:
	return win32com.client.dynamic.Dispatch( badDispatch )

fixed code

def dispFix( badDispatch ):
	import win32com.client.dynamic
	# Re-Wraps a bad dispatch into a working one:
	return win32com.client.dynamic.Dispatch( badDispatch )

app = Application
app.NewScene("", 0)
app.CreatePrim("Grid", "MeshSurface", "", "")
app.SelectGeometryComponents("grid.poly[36]")
for item in app.Selection:
	cls = dispFix(item.SubComponent.Parent3DObject.ActivePrimitive.Geometry).Polygons.SubComponent
	app.Logmessage( cls )

Finding the triangle normals for a specific polygon

To find if the polygon is 'bent' you need it's triangle normals. The polygon has a property for returning the TriangleSubIndexArray which will define the triangle but it is missing the triangle normals or a cross reference to the Polygon.Triangles array which would allow me to look up the triangle normal. This means that you need a DIY function.

def polygon_triangle_normals(in_poly):
	triIndices = in_poly.TriangleSubIndexArray
	numTriangles = in_poly.NbPoints-2
 	polyVerts = in_poly.Vertices
	
	n=[]
	for idxTri in range(0,numTriangles):
		
		p1 = polyVerts( triIndices[idxTri*3+0] ).Position
		p2 = polyVerts( triIndices[idxTri*3+1] ).Position
		p3 = polyVerts( triIndices[idxTri*3+2] ).Position
		
		e3.Sub(p2,p1)
		e1.Sub(p3,p2)
		n[idxTri].Cross(e3,e1)
		n[idxTri].NormalizeInPlace()
		
		Application.Logmesage("polygon["+str(in_poly.Index)+"].triangle["+str(idxTri)+"].normal="+str(n.Get2()))

	return n			

plugin source

# Non_Coplanar Plug-in
# Initial code generated by XSI SDK Wizard
# Executed Thu May 8 15:48:49 EDT 2008 by sinwood
# 
# Tip: To add a command to this plug-in, right-click in the 
# script editor and choose Tools > Add Command.
# 
# Tip: To get help on a callback, highlight the callback name
# (for example, "Init", "Define", or "Execute") and press F1.
import win32com.client
from win32com.client import constants

null = None
false = 0
true = 1
gDebug = 1 

NONCOPLANAR_ACUTE			= (1<<0)
NONCOPLANAR_PERPENDICULAR	= (1<<1)
NONCOPLANAR_OBTUSE			= (1<<2)

gWantedMask = NONCOPLANAR_OBTUSE

# debugging utility to avoid sending logmessages
# it will help speed things up even if the verbose 
# scripting preference is turned off.
def _Trace( in_str ) :
	if gDebug : 
		Application.LogMessage(in_str,constants.siVerbose)

def dispFix( badDispatch ):
	import win32com.client.dynamic
	# Re-Wraps a bad dispatch into a working one:
	return win32com.client.dynamic.Dispatch( badDispatch )

def XSILoadPlugin( in_reg ):
	in_reg.Author = "sinwood"
	in_reg.Name = "Non Coplanar Plug-in"
	in_reg.Email = ""
	in_reg.URL = ""
	in_reg.Major = 1
	in_reg.Minor = 0

	in_reg.RegisterFilter("NonCoplanar",constants.siFilterSubComponentPolygon)
	in_reg.RegisterProperty("Non_CoplanarPolygonTool")
	in_reg.RegisterMenu(constants.siMenuMCPSelectBottomID,"Non_CoplanarPolygonTool_Menu",false,false)
	in_reg.RegisterMenu(constants.siMenuMCPSelectSelBtnContextID,"Non_CoplanarPolygonTool_Menu",false,false)
	
	in_reg.RegisterCommand("GetNonCoplanarPolygons","GetNonCoplanarPolygons")
	
	#RegistrationInsertionPoint - do not remove this line

	return true

def XSIUnloadPlugin( in_reg ):
	strPluginName = in_reg.Name
	_Trace(str(strPluginName) + str(" has been unloaded."))
	return true

def polygon_normal( in_poly ) :
	normal = XSIMath.CreateVector3()
	colitem = XSIFactory.CreateObject("XSI.CollectionItem")
	colitem.Value = in_poly.parent.parent
	colitem.Obj.Geometry2D.Normal(in_poly.Index,normal)
	return normal
	
def polygon_isamatch( in_poly, in_wanted_mask ):
	_Trace("polygon_isamatch")
	polynormal = polygon_normal(in_poly)
	polynormal.NormalizeInPlace()
	_Trace(Application.ClassName(in_poly) +"["+ str(in_poly.index) + "].Normal=" + str(polynormal.Get2()))

	p1 = XSIMath.CreateVector3()
	p2 = XSIMath.CreateVector3()
	p3 = XSIMath.CreateVector3()
	
	e3 = XSIMath.CreateVector3()
	e1 = XSIMath.CreateVector3()

	n = XSIMath.CreateVector3()
	nFirst = XSIMath.CreateVector3()

	v1 = XSIMath.CreateVector3()
	
	triIndices = in_poly.TriangleSubIndexArray
	numTriangles = in_poly.NbPoints-2
 	polyVerts = in_poly.Vertices
 
	for idxTri in range(0,numTriangles):
		found_mask = 0
		
		p1 = polyVerts( triIndices[idxTri*3+0] ).Position
		p2 = polyVerts( triIndices[idxTri*3+1] ).Position
		p3 = polyVerts( triIndices[idxTri*3+2] ).Position
		
		e3.Sub(p2,p1)
		e1.Sub(p3,p2)
		n.Cross(e3,e1)
		n.NormalizeInPlace()
		_Trace("polygon["+str(in_poly.Index)+"].triangle["+str(idxTri)+"].normal="+str(n.Get2()))
		
		# find cross product between first triangle and triangle  N
		# they should be parallel so the resulting vector length should be 0
		if (idxTri!=0):
			v1.Cross(nFirst,n)
			if v1.Length() > 0.0000000001 :
				dot = nFirst.Dot(n)
				_Trace("dot(nFirst,n) " + str(dot))
				
				# angle is obtuse 90 < theta < 180			
				if dot < 0 :  
					found_mask |= NONCOPLANAR_OBTUSE
					_Trace("non-coplanar obtuse (90<a<=180) angle found")
				elif dot > 0 :
					found_mask |= NONCOPLANAR_ACUTE
					_Trace("non-coplanar acute (0<=a<90) angle found")
				else :
					found_mask |= NONCOPLANAR_PERPENDICULAR
					_Trace("non-coplanar right angle (a==90) found")
	
				if (found_mask & in_wanted_mask)!=0 :
					return true
		else : 	
			nFirst.Copy(n)
		
	return false
	
# Match callback for the Non_Coplanar custom filter.
def NonCoplanar_Match( in_ctxt ):
	_Trace("NonCoplanar_Match called")
	
	# Get the SubComponent object from the context
	oPolySubComponent = in_ctxt.GetAttribute( "Input" )
	
	# Return false if we don't have a SubComponent, or we don't have polygons=
	if Application.ClassName(oPolySubComponent) != "SubComponent":
		return false
		
	if oPolySubComponent.Type != "polySubComponent" :
		return false
       
	# Enumerate the selected polygons; Return false as soon as we find a polygon that doesn't match
	for oPoly in oPolySubComponent.ComponentCollection :
		if not polygon_isamatch( oPoly, _GetNonCoplanarFilterMask() ) :
			return false

	_Trace("NonCoplanar_Match found!")

	return true
	
# IsApplicable callback for the NonCoplanar custom filter.
def NonCoplanar_IsApplicable( in_ctxt ):
	_Trace("NonCoplanar_IsApplicable called")

	# 	Return value indicates if the filter is applicable for the input object.
	return true

# Subset callback for the NonCoplanar custom filter.
def NonCoplanar_Subset( in_ctxt ):
	_Trace("NonCoplanar_Subset called")

	# Get a new collection to hold the subset
	cloSubset = XSIFactory.CreateObject( "XSI.Collection" )

	# Get the collection of objects to filter
	cloInput = in_ctxt.GetAttribute( "Input" );
	
	# Enumerate the objects. If an object matches, add it to the subset
	for oItem in cloInput:
		# Get the SubComponent object
		try:
			oPolySubComponent = oItem.SubComponent
		except:
			continue
			
		if Application.ClassName(oPolySubComponent) != "SubComponent" :
			continue

		if oPolySubComponent.Type != "polySubComponent" :
			continue
			
		# This array will hold the indices of the polygons that match the filter conditions
		aIndices = []

		# Enumerate the selected polygons; if a polygon matches, put its index in the array
		for oPoly in oPolySubComponent.ComponentCollection :
			if polygon_isamatch( oPoly, _GetNonCoplanarFilterMask() ) :
				aIndices.append( oPoly.index )
		
		# Create a SubComponent from the subset of polygons that match, and add the SubComponent to the output
		_Trace("NonCoplanar_Subset num found=" + str(len(aIndices)))
		if len(aIndices) > 0  :
			oSubComponent = oPolySubComponent.Parent3DObject.ActivePrimitive.Geometry.CreateSubComponent(constants.siPolygonCluster, aIndices )
			cloSubset.Add( oSubComponent )

	# Put the subset in the Output attribute
	in_ctxt.SetAttribute( "Output", cloSubset )

	# 	Return value indicates if a subset of the input objects matches the filter criterias.
	return (cloSubset.Count > 0)

# Init callback for the NonCoplanar custom filter.
def NonCoplanar_Init( in_ctxt ):
	_Trace("NonCoplanar_Init called")
	return true

# Term callback for the NonCoplanar custom filter.
def NonCoplanar_Term( in_ctxt ):
	_Trace("NonCoplanar_Term called")
	return true

def GetNonCoplanarPolygons_Init( in_ctxt ):
	oCmd = in_ctxt.Source
	oCmd.Description = ""
	oCmd.ReturnValue = true

	oArgs = oCmd.Arguments
	oArgs.AddWithHandler("in_objects","Collection")
	oArgs.Add("in_findmask",constants.siArgumentInput,NONCOPLANAR_OBTUSE)
	return true 

class _FilterContext(object):
	Input = []
	Output = [] 
	def GetAttribute(self, attrib):
		if attrib == "Input" : return self.Input
		if attrib == "Output" : return self.Output
		return null
		
	def SetAttribute(self, attrib, value):
		if attrib == "Input" : self.Input = value 
		if attrib == "Output" : self.Output = value
		return true
		
def GetNonCoplanarPolygons_Execute( in_objects, in_findmask ):

	_Trace("GetNonCoplanarPolygons_Execute called " )

	prevWantedMask = _GetNonCoplanarFilterMask()
	_SetNonCoplanarFilterMask(in_findmask)
	
	ctx = _FilterContext()
	ctx.SetAttribute( "Input", in_objects )
	
	NonCoplanar_Subset(ctx)
	
	colSubset = ctx.GetAttribute("Output")

	try:
		colSubset.Count
	except:	
		colSubset = XSIFactory.CreateObject("XSI.Collection")
		
	_Trace("GetNonCoplanarPolygons_Execute result " + str(colSubset.GetAsText()))

	_SetNonCoplanarFilterMask(prevWantedMask)
	
	return colSubset

def Non_CoplanarPolygonTool_Define( in_ctxt ):
	oCustomProperty = in_ctxt.Source
	oCustomProperty.AddParameter2("FilterMask",constants.siInt4,4,0,8,1,8,constants.siClassifUnknown,constants.siPersistable)
	oCustomProperty.AddParameter2("MinRange",constants.siDouble,0,0,90,0,90,constants.siClassifUnknown,constants.siPersistable)
	oCustomProperty.AddParameter2("MaxRange",constants.siDouble,180,0,180,0,180,constants.siClassifUnknown,constants.siPersistable)
	oCustomProperty.AddParameter2("UseRange",constants.siBool,false,false,true,false,true,constants.siClassifUnknown,constants.siPersistable)
	return true

def Non_CoplanarPolygonTool_DefineLayout( in_ctxt ):
	oLayout = in_ctxt.Source
	oLayout.Clear()

	oLayout.AddGroup("Find Options") ;
	oLayout.AddRow()
	oItem = oLayout.AddItem( "FilterMask","Find Options", constants.siControlCheck ) ;
	oItem.LabelPercentage = 80 ;
	oItem.LabelMinPixels = 50 ;
	oItem.SetAttribute( constants.siUIValueOnly, true ) ;
	oItem.UIItems = [ "Acute", (1<<0), "Perpendicular", (1<<1), "Obtuse", (1<<2) ] 
	oLayout.EndRow()
	oLayout.EndGroup() ; 

	#oLayout.AddItem("UseRange")
	#oLayout.AddGroup("Select Range")
	#oLayout.AddItem("MinRange")
	#oLayout.AddItem("MaxRange")
	#oLayout.EndGroup()
	oLayout.AddRow()
	oLayout.AddButton("Find","Filter on polygons")
	oLayout.AddButton("Find1","Filter on objects")
	oLayout.AddButton("SetFilter", "set selection filter")
	oLayout.EndRow()

	return true

def Non_CoplanarPolygonTool_OnInit( ):
	Application.LogMessage("Non_CoplanarPolygonTool_OnInit called",constants.siVerbose)

def Non_CoplanarPolygonTool_OnClosed( ):
	Application.LogMessage("Non_CoplanarPolygonTool_OnClosed called",constants.siVerbose)
	Application.DeleteObj(PPG.Inspected)
	
def Non_CoplanarPolygonTool_FilterMask_OnChanged( ):
	Application.LogMessage("Non_CoplanarPolygonTool_FilterMask_OnChanged called",constants.siVerbose)
	_SetNonCoplanarFilterMask( PPG.FilterMask.Value )
	Application.LogMessage(str("New value: ") + str(PPG.FilterMask.Value),constants.siVerbose)

def Non_CoplanarPolygonTool_MinRange_OnChanged( ):
	Application.LogMessage("Non_CoplanarPolygonTool_MinRange_OnChanged called",constants.siVerbose)
	oParam = PPG.MinRange
	paramVal = oParam.Value
	Application.LogMessage(str("New value: ") + str(paramVal),constants.siVerbose)

def Non_CoplanarPolygonTool_MaxRange_OnChanged( ):
	Application.LogMessage("Non_CoplanarPolygonTool_MaxRange_OnChanged called",constants.siVerbose)
	oParam = PPG.MaxRange
	paramVal = oParam.Value
	Application.LogMessage(str("New value: ") + str(paramVal),constants.siVerbose)

def Non_CoplanarPolygonTool_UseRange_OnChanged( ):
	Application.LogMessage("Non_CoplanarPolygonTool_UseRange_OnChanged called",constants.siVerbose)
	oParam = PPG.UseRange
	paramVal = oParam.Value
	Application.LogMessage(str("New value: ") + str(paramVal),constants.siVerbose)

def Non_CoplanarPolygonTool_SetFilter_OnClicked( ):
	Application.LogMessage("Non_CoplanarPolygonTool_SetFilter_OnClickedcalled",constants.siVerbose)
	Application.ActivatePolygonSelToolWithNoObjStateChange(2)
	Application.SetSelFilter( "NonCoplanar")

def Non_CoplanarPolygonTool_Find_OnClicked( ):
	Application.LogMessage("Non_CoplanarPolygonTool_Find_OnClickedcalled",constants.siVerbose)
	subset = Application.GetNonCoplanarPolygons(Application.Selection, PPG.FilterMask.Value )
	if subset.count : 
		Application.SelectObj( subset )
	else :
		Application.DeselectAll()
		
def Non_CoplanarPolygonTool_Find1_OnClicked( ):
	Application.LogMessage("Non_CoplanarPolygonTool_Find1_OnClickedcalled",constants.siVerbose)

	colToSearch = XSIFactory.CreateObject("XSI.Collection")
	for item in Application.Selection:
		_Trace("isa 3do: " + str(item.IsA(constants.siX3DObjectID)))		
		_Trace("type=" + item.Type)	 	
		_Trace("classname=" + Application.ClassName(item))
		
		if Application.ClassName(item) == "X3DObject":
			if item.type == "polymsh":
				colToSearch.Add( item.ActivePrimitive.Geometry.Polygons.SubComponent )
		elif Application.ClassName(item) == "CollectionItem" :
			if item.Type == "polySubComponent" :
				colToSearch.Add( dispFix(item.SubComponent.Parent3DObject.ActivePrimitive.Geometry).Polygons.SubComponent )
		
	_Trace("objects to search: " + str(colToSearch))		
	Application.ActivatePolygonSelToolWithNoObjStateChange(2)
	
	subset = Application.GetNonCoplanarPolygons(colToSearch, PPG.FilterMask.Value )
	
	if subset.count : 
		Application.SelectObj( subset )
	else :
		Application.DeselectAll()
		
def Non_CoplanarPolygonTool_Menu_Init( in_ctxt ):
	oMenu = in_ctxt.Source
	oMenu.AddCallbackItem("Non_CoplanarPolygonTool","OnNon_CoplanarPolygonToolMenuClicked")
	return true

def OnNon_CoplanarPolygonToolMenuClicked( in_ctxt ):
	Application.AddProp("Non_CoplanarPolygonTool")
	return 1

def _GetNonCoplanarFilterMask():
	global gWantedMask
	return gWantedMask

def _SetNonCoplanarFilterMask( in_FilterMask ):
	global gWantedMask
	gWantedMask=int(in_FilterMask)
	return gWantedMask


This page was last modified 16:11, 28 May 2008.
This page has been accessed 63659 times.

© Copyright 2009 Autodesk Inc. All Rights Reserved. Privacy Policy | Legal Notices and Trademarks | Report Piracy