Friday, March 20, 2015

The RevitPythonShell moved to GitHub

The RevitPythonShell has moved to GitHub! You can find the project here: https://github.com/architecture-building-systems/revitpythonshell

Google Code is shutting down so I moved the code to GitHub. I’m currently working on fixing / creating the wiki pages, but you should be able to download the latest release already.

Thursday, March 19, 2015

More control over the RibbonPanel in RevitPythonShell

This post explains how to exert more control over the items shown in the RibbonPanel by your RevitPythonShell scripts.

The default behaviour of RevitPythonShell for external scripts is to group them into split buttons and place the remaining scripts in stacks of up to three. External scripts that are assigned the same “Group” value are placed together in a split button - in the example below, the external scripts “Button five”, “Button six” and “Button seven” are all assigned the group value “Group 5-7”.

After saving your changes, you need to restart Revit to see any changes. The Ribbon will then include this panel:

This is fine for collecting your personal scripts and while developing new scripts. When it comes to publishing a plugin (even inside your organization) you might want more control of how the buttons appear.

NOTE: When you create a RpsAddin for deployment, you use a slightly different approach by specifying the buttons in the RpsAddin xml file. But you are still limited to PushButtons.

The Revit API contains a selection of controls you can use to execute scripts that you can use instead. You can find out all about these in the Revit API Developers Guide (Ribbon Panels and Controls).

The Revit API expects you to build your Ribbon panels and controls while the application is starting up - during the IExternalApplication.OnStartup method of a plugin. After that, the access to the UIControlledApplication which is needed to alter the Ribbon is not available anymore. Therefore, you can only access the UIControlledApplication object in the RPS startup script - through a special variable called __uiControlledApplication__. The startup script in RPS gives you access to the very variable it uses to configure its own user interface. All we need to do now is hook up an RPS script with a button on the ribbon. Let us assume a very simple script:

"""
helloworld.py - prints a greeting
"""
print 'hello, world!'

(you can find this script in the rps-sample-scripts project on GitHub)

This script shall be called whenever the user clicks a button or other control on our custom Ribbon panel.

There is just one problem here. When you create a control on a Revit Ribbon panel, you need to pass in the path to a DLL and the fully qualified name (including namespaces) of a class inside that DLL that implements IExternalCommand. That does not sound like a python script at all!

To support this, the RevitPythonShell exposes a special class called ExternalCommandAssemblyBuilder which can create such a DLL. To use it, you just pass in a dictionary of class names and the corresponding python script files. The DLL created is just a very thin wrapper that calls into the RpsRuntime.dll (yes, that means you can use this for your RpsAddins!) with the script path and gets them executed.

So, a simple startup script that creates a Ribbon panel with a single PushButton would look like this:

'''
simple_ribbon.py - creates a ribbon panel with a single push button.

NOTE:
- this MUST be set as a startup script for it to work
- the RPS variable "EXAMPLES_PATH" must be set and contain "helloworld.py"
'''

# script that is run when Revit starts in the IExternalApplication.Startup event.
try:
import os
from RevitPythonShell.RpsRuntime import ExternalCommandAssemblyBuilder
from Autodesk.Revit.UI import *

SCRIPT_PATH = os.path.join(__vars__['EXAMPLES_PATH'], "helloworld.py")
DLL_PATH = os.path.expandvars(r"%APPDATA%\RevitPythonShell2015\simple_ribbon.dll")
print 'storing external command assembly here:', DLL_PATH

builder = ExternalCommandAssemblyBuilder()
builder.BuildExternalCommandAssembly(
DLL_PATH,
{'HelloWorld': SCRIPT_PATH})

panel = __uiControlledApplication__.CreateRibbonPanel('simple_ribbon')

pbd = PushButtonData('pb_HelloWorld', 'hello, world!', DLL_PATH, 'HelloWorld')

#__window__.Close()  # closes the window
except:
import traceback       # note: add a python27 library to your search path first!
traceback.print_exc()  # helps you debug when things go wrong

(NOTE: you can find this script in the rps-sample-scripts repository on GitHub)

The script (and all following scripts in this post) assumes you have the directory structure of the rps-sample-scripts repository downloaded to your machine - a simple git clone should do the trick - and have set the RPS variable EXAMPLES_PATH to point to that folder. You could also just edit the script text to hard code the paths and live with the nasty code smell…

Adding simple_ribbon.py as your startup script will result in a new ribbon panel added to RPS:

When clicked, you will be greeted as expected.

There are a few things going on here, so let’s go through them one by one. First, the whole body of the script is wrapped in a try/except. This is important, since a crash in the script might bring down the whole of Revit and you will not know where it happened - so we use traceback.print_exc() to print an exception trace. This is a handy debugging tip for your RPS scripting skill set!

Next, we create an assembly for Revit to load when you click the push button. The arguments to builder.BuildExternalCommandAssembly include the path of the assembly to be created. I chose to place it in the same folder as the CommandLoaderAssembly.dll - which is a similar dll created by RPS for the external scripts defined in the Configure dialog. You can place it anywhere the script has write access to.

Adding a panel to the ribbon and adding a push button to the panel is basically just an exercise of translating the sample c# code from the SDK into python. Adding images is also possible. Consider the following revised version:

% simple_ribbon_with_icons.py (includes images)

This produces a Ribbon panel that looks like this:

As far as I can tell, you only really need to set the LargeImage property of the PushButtonData object - the Image property seems to be an atavism from an ancient version of Revit…

The rps-sample-scripts repository on GitHub contains a translation of the New Ribbon Panel example from the Revit API Developer’s Guide:

"""
new_ribbon_panel.py - a startup script to create a selection of
controls on the ribbon.

This script is based on the New Ribbon Panel and Controls example in the
Revit API Devolopers Guide.

NOTE:
- this MUST be set as a startup script for it to work
- the RPS variable "EXAMPLES_PATH" must be set and contain "helloworld.py"
"""

import os
import clr
from System.Windows.Media.Imaging import BitmapImage
from System import Uri
from RevitPythonShell.RpsRuntime import ExternalCommandAssemblyBuilder
from Autodesk.Revit.UI import *

SCRIPT_PATH = os.path.join(__vars__['EXAMPLES_PATH'], "helloworld.py")
LARGE_IMG_PATH = os.path.join(__vars__['EXAMPLES_PATH'], "PythonScript32x32.png")
SMALL_IMG_PATH = os.path.join(__vars__['EXAMPLES_PATH'], "PythonScript16x16.png")
EXAMPLES_PATH = __vars__['EXAMPLES_PATH']
DLL_PATH = os.path.expandvars(r"%APPDATA%\RevitPythonShell2015\simple_ribbon.dll")
print 'storing external command assembly here:', DLL_PATH

def create_ribbon_panel():
panel = __uiControlledApplication__.CreateRibbonPanel("New Ribbon Panel")

tb1 = ToggleButtonData("toggleButton1", "Red")
tb1.ToolTip = "Red Option"
tb1.LargeImage = BitmapImage(Uri(os.path.join(
EXAMPLES_PATH, 'StartupScripts', 'red.png')))

tb2 = ToggleButtonData("toggleButton2", "Green")
tb2.ToolTip = "Green Option"
tb2.LargeImage = BitmapImage(Uri(os.path.join(
EXAMPLES_PATH, 'StartupScripts', 'green.png')))

tb3 = ToggleButtonData("toggleButton3", "Blue")
tb3.ToolTip = "Blue Option"
tb3.LargeImage = BitmapImage(Uri(os.path.join(
EXAMPLES_PATH, 'StartupScripts', 'blue.png')))

PushButtonData("pb_HelloWorld", "Hello, world!",
DLL_PATH, "HelloWorld"))
push_button.ToolTip = "Say hello world"
context_help = ContextualHelp(ContextualHelpType.Url, "http://www.autodesk.com")
push_button.SetContextualHelp(context_help)

push_button.LargeImage = BitmapImage(Uri(LARGE_IMG_PATH))

button_one = PushButtonData("pbButtonOne", "Option one",
DLL_PATH, "HelloWorld")
button_one.LargeImage = BitmapImage(Uri(os.path.join(
EXAMPLES_PATH, 'StartupScripts', 'one.png')))

button_two = PushButtonData("pbButtonTwo", "Option two",
DLL_PATH, "HelloWorld")
button_two.LargeImage = BitmapImage(Uri(os.path.join(
EXAMPLES_PATH, 'StartupScripts', 'two.png')))

button_three = PushButtonData("pbButtonThree", "Option three",
DLL_PATH, "HelloWorld")
button_three.LargeImage = BitmapImage(Uri(os.path.join(
EXAMPLES_PATH, 'StartupScripts', 'three.png')))

"""Add a text box and combo box as stacked items"""
combo_box_data = ComboBoxData("comboBox")
text_data = TextBoxData("Text Box")
text_data.Image = BitmapImage(Uri(SMALL_IMG_PATH))
text_data.Name = "Text Box"
text_data.ToolTip = "Enter some text here"
text_data.LongDescription = """This is text that will appear next to the image
when the user hovers the mouse over the control"""
text_data.ToolTipImage = BitmapImage(Uri(LARGE_IMG_PATH))

text_box = stacked_items[0]
text_box.PromptText = "Enter a comment"
text_box.ShowImageAsButton = True
text_box.ToolTip = "Enter some text"
text_box.EnterPressed += lambda sender, args: TaskDialog.Show('new_ribbon_panel', sender.Value)

combo_box = stacked_items[1]
combo_box.ItemText = "ComboBox"
combo_box.ToolTip = "Select an Option"
combo_box.LongDescription = "Select a number or letter"

member_data_a = ComboBoxMemberData('A', 'Option A')
member_data_a.Image = BitmapImage(Uri(os.path.join(
EXAMPLES_PATH, 'StartupScripts', 'a.png')))
member_data_a.GroupName = 'Letters'

member_data_b = ComboBoxMemberData('B', 'Option B')
member_data_b.Image = BitmapImage(Uri(os.path.join(
EXAMPLES_PATH, 'StartupScripts', 'b.png')))
member_data_b.GroupName = 'Letters'

member_data_c = ComboBoxMemberData('C', 'Option C')
member_data_c.Image = BitmapImage(Uri(os.path.join(
EXAMPLES_PATH, 'StartupScripts', 'c.png')))
member_data_c.GroupName = 'Letters'

member_data_1 = ComboBoxMemberData('1', 'Option 1')
member_data_1.Image = BitmapImage(Uri(os.path.join(
EXAMPLES_PATH, 'StartupScripts', 'one_small.png')))
member_data_1.GroupName = 'Numbers'

member_data_2 = ComboBoxMemberData('2', 'Option 2')
member_data_2.Image = BitmapImage(Uri(os.path.join(
EXAMPLES_PATH, 'StartupScripts', 'two_small.png')))
member_data_2.GroupName = 'Numbers'

member_data_3 = ComboBoxMemberData('3', 'Option 3')
member_data_3.Image = BitmapImage(Uri(os.path.join(
EXAMPLES_PATH, 'StartupScripts', 'three_small.png')))
member_data_3.GroupName = 'Numbers'

pass  # left as exercise for the reader :)

if __name__ == '__main__':
try:
builder = ExternalCommandAssemblyBuilder()
builder.BuildExternalCommandAssembly(
DLL_PATH,
{'HelloWorld': SCRIPT_PATH})
create_ribbon_panel()
except:
import traceback
traceback.print_exc()

If you are really interested on what is produced by the ExternalCommandAssemblyBuilder, just use JetBrains dotPeek to decompile the simple_ribbon.dll. When I tried, I ended up with this:

I’ll leave it as an exercise to the reader to get on GitHub and check out the source for RpsExternalCommandScriptBase - it has a constructor that saves the script path as a member to be called when Revit decides to Execute it.

Friday, February 6, 2015

Batteries included!

The newest versions of the RevitPythonShell include a copy of the python standard library. This copy is taken from IronPython. This means you don’t need to add a search path to a local python distribution when importing standard python modules.

Since the library is included in a zip file embedded in the RpsRuntime.dll, it is also automatically deployed with when creating RpsAddIns.

The versions supporting this feature are:

• (r223) Installer for Autodesk Revit 2015 (experimental)
• Installer for Autodesk Vasari (Beta 3)
• List item

Thursday, January 15, 2015

Introducing the non-modal shell in RevitPythonShell (r223)

You have no idea how excited I am about this release! Revision r223 of the RevitPythonShell has been tested by a select few very brave people (Ehsan Iran Nejad and Callum Freeman) and seems to work. Normally I’m not so worried about pushing out new versions, but this one is… different.
The idea actually came from Ehsan. The Next Big Thing. Also known as the non-modal shell.
I know, I know - I need to get a lot better at coming up with good names for the various parts in RevitPythonShell, but for now, that is what it is called: Non-modal shell.
The non-modal shell is different from the standard RevitPythonShell shell in that it is… well… non-modal. What does that mean? Currently, when you start a shell in RPS, the shell gets the focus and you can’t interact with the rest of the Revit GUI until you close the shell again. This is cool for working with short scripts, trying stuff out etc, but we can do better:
The non-modal shell does not block the rest of Revit. You can select stuff in the view, make changes to the BIM etc. And then query the selection in the shell or do whatever you like.
With some exceptions.
You see, because the shell is non-modal, it is not running in the special thread Revit needs you to be in for accessing the API. You would get a lot of ugly errors, if there wasn’t some clever magic going on in the background that sends all input to the interactive shell to an implementation of IExternalEventHandler which Revit then runs in the correct thread and everything is fine and dandy.
What doesn’t work though, is using transactions in the interactive shell:
>>> t = Transaction(doc, 'test')
>>> t.Start()
Autodesk.Revit.DB.TransactionStatus.Started
>>> t.GetStatus()
Autodesk.Revit.DB.TransactionStatus.RolledBack
>>> t.Commit()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
Exception: The transaction has not been started yet (the current status is not 'Started'). A transaction must be started before it can be either committed or rolled back.
>>>

You can get around this by using the IronPython Pad - the little editor below the interactive shell. This allows you to run multiple lines at a time and those will be run in the same IExternalEventHandler so the problem does not show up.
You can find the installer for this (experimental) version of RevitPythonShell in the Downloads section of the project homepage.
I intend to post some more articles in the future that showcase some other new features in the r223 release, so you can expect some more goodies soon :)

Monday, December 22, 2014

A short tutorial on the esoreader module

This post explains how to use the esoreader, a python module for parsing the .eso file produced by EnergyPlus. It also includes a small (incomplete) reverse engineering of the .eso file format.
EnergyPlus is a whole building simulation tool EnergyPlus is a whole building energy simulation program that engineers, architects, and researchers use to model energy and water use in buildings. The .eso file is the main output file produced by EnergyPlus and is normally parsed by tools that come with the EnergyPlus suit of tools. The esoreader module lets you read in the time series data in python scripts, which for my research is quite useful. I published the module thinking other people might want to do so too.
Last week I got an email about how the documentation on the pypi page for the esoreader module is rather… terse. So I went to check the esoreader page and yes, there is not a lot of documentation. The example code I published was this:
import esoreader
PATH_TO_ESO = r'/Path/To/EnergyPlus/Output/eplusout.eso'
frequency, key, variable = dd.find_variable(
'Zone Ventilation Total Heat Loss Energy')[0]
idx = dd.index[frequency, key, variable]
time_series = data[idx]

What can I say? There is not much more you can do with esoreader. I think the best way to understand the module is to look at the .eso file format:
The eso file format starts of with a header section called the “data dictionary” (I used the variable dd in the example code for that). The first few lines of a sample eso file look something like this:
Program Version,EnergyPlus-Windows-32 8.1.0.009, YMD=2014.03.20 14:18
1,5,Environment Title[],Latitude[deg],Longitude[deg],Time Zone[],Elevation[m]
2,6,Day of Simulation[],Month[],Day of Month[],DST Indicator[1=yes 0=no],Hour[],StartMinute[],EndMinute[],DayType
3,3,Cumulative Day of Simulation[],Month[],Day of Month[],DST Indicator[1=yes 0=no],DayType  ! When Daily Report Variables Requested
4,2,Cumulative Days of Simulation[],Month[]  ! When Monthly Report Variables Requested
5,1,Cumulative Days of Simulation[] ! When Run Period Report Variables Requested
6,1,DEFAULT_ZONE,Zone Outdoor Air Drybulb Temperature [C] !TimeStep
99,1,DPVWALL:1157026,Surface Outside Face Temperature [C] !TimeStep
100,1,DPVWINDOW:COMBINED:DPVWALL:1157026:DEFAULTWINDOWCONSTRUCTION,Surface Outside Face Temperature [C] !TimeStep
101,1,DPVWALL:1157027,Surface Outside Face Temperature [C] !TimeStep
102,1,DPVWINDOW:COMBINED:DPVWALL:1157027:DEFAULTWINDOWCONSTRUCTION,Surface Outside Face Temperature [C] !TimeStep
103,1,DPVWALL:1157028,Surface Outside Face Temperature [C] !TimeStep
104,1,DPVWINDOW:COMBINED:DPVWALL:1157028:DEFAULTWINDOWCONSTRUCTION,Surface Outside Face Temperature [C] !TimeStep
105,1,DPVWALL:1157029,Surface Outside Face Temperature [C] !TimeStep
106,1,DPVWINDOW:COMBINED:DPVWALL:1157029:DEFAULTWINDOWCONSTRUCTION,Surface Outside Face Temperature [C] !TimeStep
107,1,DPVFLOOR:1157042,Surface Outside Face Temperature [C] !TimeStep
108,1,DPVROOF:1157058.0,Surface Outside Face Temperature [C] !TimeStep
109,1,DPVROOF:1157058.1,Surface Outside Face Temperature [C] !TimeStep
110,1,DPVROOF:1157058.2,Surface Outside Face Temperature [C] !TimeStep
111,1,DPVROOF:1157058.3,Surface Outside Face Temperature [C] !TimeStep
112,1,DEFAULT_ZONE,Zone Mean Air Temperature [C] !TimeStep
End of Data Dictionary

The first line is stored in the DataDictionary object (dd) as version and timestamp. After that, each line represents a variable being reported. Each such variable has an index, a number of values being reported and then a reporting frequency. Well… the first few lines (indexes 1 through 5) are a bit special and I just discard them. The rest of the data dictionary lines are built like this:
• index (e.g. 100)
• column count (is always one as far as I can tell)
• key (the same variable can be measured for different keys, as per the Output:Variable object in the IDF file)
(e.g. “DPVWINDOW:COMBINED:DPVWALL:1157026:DEFAULTWINDOWCONSTRUCTION”, a surface name in one of my models)
• variable name (e.g. “Surface Outisde Face Temperature”)
• unit (e.g. “C”)
• reporting frequency (e.g. “TimeStep”)
These get parsed into a DataDictionary object and stored in the attributes variables and index.
variables = dict of ids, int => [reporting_frequency,
key, variable, unit]

index = dict {(key, variable, reporting_frequency) => id)}

here is an example (I’m using the IPython shell, in case you’re wondering about the In [71] line - check it out! it is awesome!!)
In [71]: dd.variables.items()[1]
Out[71]:
(100,
['TimeStep',
'DPVWINDOW:COMBINED:DPVWALL:1157026:DEFAULTWINDOWCONSTRUCTION',
'Surface Outside Face Temperature',
'C'])

The DataDictionary object has a method find_variable. Say, you want to find the variable for ‘Zone Mean Air Temperature’:
In [75]: dd.find_variable('Zone Mean Air Temperature')
Out[75]: [('TimeStep', 'DEFAULT_ZONE', 'Zone Mean Air Temperature')]

Notice how the result is a list? If you had looked for surface temperatures instead:
In [76]: dd.find_variable('surface')
Out[76]:
[('TimeStep', 'DPVROOF:1157058.1', 'Surface Outside Face Temperature'),
('TimeStep', 'DPVWALL:1157029', 'Surface Outside Face Temperature'),
('TimeStep',
'DPVWINDOW:COMBINED:DPVWALL:1157029:DEFAULTWINDOWCONSTRUCTION',
'Surface Outside Face Temperature'),
('TimeStep', 'DPVWALL:1157028', 'Surface Outside Face Temperature'),
('TimeStep', 'DPVROOF:1157058.3', 'Surface Outside Face Temperature'),
('TimeStep', 'DPVROOF:1157058.0', 'Surface Outside Face Temperature'),
('TimeStep',
'DPVWINDOW:COMBINED:DPVWALL:1157028:DEFAULTWINDOWCONSTRUCTION',
'Surface Outside Face Temperature'),
('TimeStep',
'DPVWINDOW:COMBINED:DPVWALL:1157026:DEFAULTWINDOWCONSTRUCTION',
'Surface Outside Face Temperature'),
('TimeStep', 'DPVWALL:1157027', 'Surface Outside Face Temperature'),
('TimeStep',
'DPVWINDOW:COMBINED:DPVWALL:1157027:DEFAULTWINDOWCONSTRUCTION',
'Surface Outside Face Temperature'),
('TimeStep', 'DPVFLOOR:1157042', 'Surface Outside Face Temperature'),
('TimeStep', 'DPVWALL:1157026', 'Surface Outside Face Temperature'),
('TimeStep', 'DPVROOF:1157058.2', 'Surface Outside Face Temperature')]

you’d have gotten a list of all variables that match ‘surface’ (case-insensitive, substring match). The tuples define the variable you’re looking for: Frequency, key and variable name, since you can have the same variable output for different frequencies and keys!
So the index of the variable we’re looking for (Zone Mean Air Temperature) can be found like this:
In [77]: dd.index['TimeStep', 'DEFAULT_ZONE', 'Zone Mean Air Temperature']
Out[77]: 112

When you parse an .eso file, you get two values back: The DataDictionary and the data itself, which is stored in a simple dictionary mapping the variable index to the timeseries data:
In [82]: dd, data = esoreader.read('RevitToCitySim_fmibeta.eso')

In [84]: data[112]
Out[84]:
[19.9999999999999,
20.0,
20.0,
20.0,
20.0,
20.0,
20.0,

Where does that data come from? From the rest of the .eso file, which looks like this:
1,Zuerich-SMA - - TMY2-66600 WMO#=,  47.38,   8.57,   1.00, 556.00
2,1, 1, 1, 0, 1, 0.00,60.00,Tuesday
6,-9.733141026918536E-003
99,4.22860281958676
100,2.29752216466107
101,4.62549195332972
102,2.48360878690238
103,4.45346228786434
104,2.40363283464546
105,4.29531948374435
106,2.37522804444541
107,18.
108,4.50377052140968
109,4.62335191215081
110,4.38341749556803
111,4.62165617120029
112,19.9999999999999
278,69070426.0448551
279,2.200249582529068E-006
2,1, 1, 1, 0, 2, 0.00,60.00,Tuesday
6,-20.0097331410269
99,-3.72067959222121
100,-11.9333570144822
101,-3.2185453285921
102,-11.9539672821419
103,-3.21604754356428
104,-11.6999602208759
105,-3.69978393125912
106,-12.1422252778574
107,18.
108,-0.183133489472591
109,0.134352957894094
110,-0.291113509003108
111,9.541793763769267E-002
112,20.
278,12241499.5530833
279,0.0
2,1, 1, 1, 0, 3, 0.00,60.00,Tuesday
6,-20.0097331410269
99,-8.25275818278861
100,-10.9050845966823

For all the main variables (id > 5) the format is:
• index
• value
Thus, the data dictionary is necessary to figure out what variables (with what frequency) are being output.
To sum up the tutorial: The code on the pypi page shows you pretty much all you can do and also all you need to do to retrieve a specific timeseries from an .eso file:
• read in the eso file to obtain the data dictionary and the data
• find the key, frequency and variable name you need in the data dictionary (with find_variable) or by guessing from your IDF input
• retrieve the index of that variable
• retrieve the time series data using that index

Thursday, October 17, 2013

How to replace a color in a PNG with python preserving transparency

Here is a small bit of code I thought I'd share with you, since I couldn't find a good solution on the web quickly...

I was coding up a web site with a horizontal navigation menu that used a downwards pointing arrow to denote a menu entry that has a drop down menu. The graphics designer had given me the image for this arrow. Here it is enlarged in a GIMP window:

Which is all fine and dandy, except, when the mouse hovers above the menu item, the arrow should turn cyan. #00aeef.

I was just about to ask the designer for the cyan version of the arrow, after failing to find a quick and easy way to change all the white pixels to cyan preserving transparency, when I decided to see how hard that would be to do with the Python Imaging Library (PIL).

This is the throwaway script I came up with:

OLD_PATH = r'c:\path\to\images\arrow_white.png'
NEW_PATH = r'c:\path\to\images\arrow_cyan.png'

R_OLD, G_OLD, B_OLD = (255, 255, 255)
R_NEW, G_NEW, B_NEW = (0, 174, 239)

import Image
im = Image.open(OLD_PATH)

width, height = im.size
for x in range(width):
for y in range(height):
r, g, b, a = pixels[x, y]
if (r, g, b) == (R_OLD, G_OLD, B_OLD):
pixels[x, y] = (R_NEW, G_NEW, B_NEW, a)
im.save(NEW_PATH)


As you can see, that was not so difficult at all... And produced this result:

Let's just go through this quickly... OLD_PATH and NEW_PATH should be self explaining.

Next, I configure the R(ed), G(reen) and B(lue) values of the color I'm looking for and the color I want to change that to. White is easy, you should be able to do that in your head, but for the cyan bit, the python interpreter can help us:

>>> int('00', 16)
0
>>> int('ae', 16)
174
>>> int('ef', 16)
239
>>>


Converting bytes from hex to decimal is equivalent to parsing the hex string as an int with base 16. The other way round is even easier:

>>> hex(0)
'0x0'
>>> hex(174)
'0xae'
>>> hex(239)
'0xef'
>>>


Next, I import the imaging library and open the image. The load() method returns a map of the image, which each x,y-coordinate denoting a tuple (r, g, b, a) with a being the alpha channel or transparency.

The rest of the script is therefor just a simple iteration over the width and height of the image, pixel by pixel, testing for the old color and replacing that with the new color if found, preserving the original transparency.

Thursday, May 30, 2013

New Feature: StartupScript in RevitPythonShell

Here is a new hidden feature in recent versions of the RevitPythonShell: You can specify a script to start when Revit starts. The script is called during the IExternalApplication.OnStartup event when all the Revit plugins get to do their initialization.

This feature is still not quite official yet, but I have been using it for various purposes internally and think it works quite nicely. There are some changes between the code I have locally and what you probably have installed - I will point these out below.

To specify a StartupScript for RevitPythonShell, add a tag called StartupScript to the RevitPythonShell.xml file at the same level as the Commands, Variables, SearchPaths and InitScript tags. This tag has a single attribute src that specifies the path to the script to run.

You can find the RevitPythonShell.xml file in the folder %APPDATA%\RevitPythonShell2013.

This feature is also present in deployed RpsAddins!

Example:

<?xml version="1.0" encoding="UTF-8"?>
<RevitPythonShell>
<StartupScript src="c:\path-to-your-script\your-awesome-startup-script.py"/>
<Commands>
<Command name="Hello World" src="C:\...\helloworld.py" group="Examples"/>
<!-- ... -->


NOTE:

If you are not building from source, then the attribute to use in the StartupScript tag is still source. I changed it to src for consistency with the Command tag. Future versions of RevitPythonShell (especially the 2014 line) will use src as described in this post!

There is currently no GUI for changing the StartupScript. Also, this is somewhat of a specialist feature: You probably don't want to use it except for some very special cases.

So... what can you do with a startup script? So far, I have used it for two projects...

In the first project, I load a web server and hook it up to the Idling event so that I can provide an HTTP interface to Revit. My day job involves extracting an abstract model of simulation relevant aspects of a building from Revit, much like gbXML and being able to do that from outside Revit gives me some flexibility.

In the second project, I was automating a solar study: For each window in an elaborate model, I traced rays from a grid on the window to the sun from the SunAndShadowSettings to determine the percentage of direct sunlight a window gets - for every 15 minutes in a whole year! Since the model was big, with a lot of neighboring buildings modeled as mass objects, ray tracing (using the FindReferencesByDirectionWithContext method) gets very slow. Simulating a single day takes about 20 minutes. And simulating the whole year just broke down with Revit crashing. So... I decided to do each day separatly, and just start Revit after each day - the startup script would check the __vars__[START_DAY] variable to get the current day to simulate and on successful simulation, increment that day (this is where the writeable __vars__ feature was born) and quit Revit. Then I wrapped that up in a simple script that just started looped 365 times, starting Revit and waiting for it to finish.

Side note: quitting Revit is not that easy. Or really easy, whichever way you want to view it. I ended up using this code to force the process to die:

from System.Diagnostics import Process
p = Process.GetCurrentProcess()
p.Kill()