My favorites | English | Sign in

Faster JavaScript with Closure Tools New!

Google SketchUp Ruby API

Attribute Reporting

SketchUp's Ruby API has a robust attribute attachment capability that allows you to add metadata of almost any kind to the entities in your model. For example, one could attach a series of attributes to a Group that looks like a wooden board to record what kind of wood it's made out of, how much it weighs, etc.

Attributes are stored as name/value pairs inside an arbitrarily named AttributeDictionary. An Entity can have an unlimited number of AttributeDictionaries, and each of those can contain an unlimited number of attributes.

In the board example given above, the group in question might have five attributes that fall into two categories. Take a look at this code to understand how they are attached...

# Assumes that the board group is the first entity in the model.
board = Sketchup.active_model.entities[0]

# Attach some attributes under a dictionary called "physical_properties"
board.set_attribute "physical_properties", "species", "Douglas Fir"
board.set_attribute "physical_properties", "weight_in_kg", 12.5
board.set_attribute "physical_properties", "density_in_kg_per_cubic_meter", 530

# Attach some attributes under a dictionary called "product_details"
board.set_attribute "product_details", "sku", "DF2x4x48"
board.set_attribute "product_details", "cost", 12.95

 
Once an attribute is attached, it will be preserved even if that entity is saved to disk, copied and pasted into another file, or grouped inside another entity. See the Entity, AttributeDictionaries, and AttributeDictionary classes for details on how to access existing attributes.

For a tool that allows you to easily inspect attributes attached to SketchUp entities, try the open-source plugin, SketchUp Attribute Manager.

Dynamic Component Attributes

SketchUp Pro 7's provides a code-free way for you to attach attributes to Components and Groups. It also provides a Generate Report feature that lets you get a tabular list of all Dynamic Component attributes that have been created throughout a model.

Since the DC feature is built using the Ruby API, the attributes that it creates are accessible via the same Ruby commands that we described above. This means that it's relatively easy for you to build a custom report that reads DC attributes. Some examples of this might include:

  1. Getting a purchase order out of SketchUp that includes every product from a given manufacturer.
  2. Getting a bill of materials out of SketchUp for every piece of furniture in a room, and emailing that to a central repository.
  3. Using an existing piece of pricing software to read a text file that SketchUp generates and return a price based off of DC configuration attributes.

Depending on your needs, the examples above might be achievable by relying on the Generate Report feature of SketchUp Pro, but if you find yourself wanting to create something more customized, we have some sample code to help you.

All Dynamic Component attributes are attached to ComponentDefinitions and ComponentInstances, in an AttributeLibrary called "dynamic_attributes". If you write a Ruby script to inspect that Library, you will discover two kinds of attributes: those that start with an underscore (_) are "internal" attributes that the plugin uses to track metadata about a given external attribute. For example, you might find an attribute called "weight" and a series of internal attributes such as "_weight_label", "_weight_formula", etc.

There is an open source SketchUp Attribute Manager that can make exploring a large set of attributes much easier. Visit http://code.google.com/p/sketchupattributemanager/wiki/Welcome to install it. This will allow you to view DC attributes without having to write ruby scripts to do so.

Attribute Reporter Script

The following code demonstrates how to read every DC Attribute in a model and generate a text file report to disk. You can copy and paste this code into your text editor, save it as attrreporter.rb into your plugins directory, and restart SketchUp. If you select some components and right click on your selection, you will see a new context menu item for "Generate Selection Attributes Report".

By modifying this sample, you could easily create more powerful or customized capabilities for your specific needs.

#----------------------------------------------------------------------------#
## Copyright 2005-2008, Google, Inc.

# This software is provided as an example of using the Ruby interface
# to SketchUp.

# Permission to use, copy, modify, and distribute this software for 
# any purpose and without fee is hereby granted, provided that the above
# copyright notice appear in all copies.

# THIS SOFTWARE IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR
# IMPLIED WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.
#----------------------------------------------------------------------------#

require 'sketchup.rb'


# AttributeReporter class, provide useful reporting for the attributes attached to your
# Components and Groups
#
# Put this file in the Plugins directory and you should be good to go.
# You will have a right click to save attributes information for a selection and 
# a menu under Plugins to save the whole Model attributes information.  
class AttrReporter
      
  # This method set up the variables that we need to create and the format template.
  # Currently we support CSV and HTML. 
  # More format can be easily defined here by defining the @filetype expected and the
  # various variables @doc_start, @doc_end, @row_start, @row_end, @cell_start, @cell_mid, and @cell_end
  # 
  #   Args:
  #      filename: name of the file to save        
  #   Returns:
  #      None
  #      
  def set_up(filename)
    
    # Arrays
    @group_list = []
    @component_list = []

    # Dictionary where the DC attributes are stored.
    @dictionary_name = "dynamic_attributes"
   
    # Create some global structures to store our report data in as
    # it is built. Note that this is a RAM intensive approach, so extremely
    # large reports could run into memory problems.
    @report_data = []
    @totals_by_att_name = {}

    # This array will contain an ordered list of the attribute names we've
    # encountered as we walk the model.
    @report_attribute_list = []

    # The @title_array will contain an ordered list of all of the "column
    # titles" to match the @report_attribute_list attributes we've found.
    @title_array = []

    # Oh, and there are a few columns that we hard code into the output that
    # aren't strictly "attributes" from a Ruby API perspective. So pop those
    # into the column list.
    @title_array.push('ENTITY')
    @title_array.push('DEFINITION NAME')
    @title_array.push('ENTITY DESCRIPTION')
    @title_array.push('LAYER')

    # Calculate the file type based on the characters after the last dot in the file name.
    @filetype = (filename.split('.').last).downcase
    @filename = filename 

    # In an effort to allow for extending the report formats down the
    # road, the reporter uses a simple templating system that allows you to
    # define strings that start and end the report, the rows, and the cells.
    # you can easily add more formats here 
    if @filetype == "csv"
    
      @doc_start  = ""
      @doc_end    = ""
      @row_start  = ""
      @row_end    = "\n"
      @cell_start = ""
      @cell_mid   = ","
      @cell_end   = ","

      else # default to html
      
        @doc_start = "<html><head><meta http-equiv=\"Content-Type\" " +
        "content=\"text/html; charset=utf-8\"></head>\n<style>" +
        "table {\n" +
        "  padding: 0px;\n" +
        "  margin: 0px;\n" +
        "  empty-cells: show;\n" +
        "  border-right: 1px solid silver;\n" +
        "  border-bottom: 1px solid silver;\n" +
        "  border-collapse: collapse;\n" +
        "}\n" +
        "td {\n" +
        "  padding: 4px;\n" +
        "  margin: 0px;\n" +
        "  border-left: 1px solid silver;\n" +
        "  border-top: 1px solid silver;\n" +
        "  font-family: sans-serif;\n" +
        "  font-size: 9pt;\n" +
        "  vertical-align: top;\n" +
        "}\n</style>\n" +
        "<table border=1>"
        @doc_end    = "</table></html>"
        @row_start  = "   <tr>\n"
        @row_end    = "   </tr>\n"
        @cell_start = "    <td>"
        @cell_mid   = "</td>\n    <td>"
        @cell_end   = "</td>\n"

    end
  end
      
  #these are some functions useful for formatting the generated output 
  # Processes anything into a float.
  #  Args:
  #     value  The value to convert.
  # Returns:
  #     The value as a float.
  def to_number(value)
    # If we're passed a string, strip off any characters that are not digits,
    # periods, or minus signs.
    if value.kind_of? String
      # If the first character is anything but a digit, period, or a minus sign,
      # then this string is not convertable, so return 0.0. Otherwise, a 
      # string like "myPart!x/1000+75*99280" would parse down to 10007599280.
      if value =~ /^[^\d\.\-]/
        return 0.0
      end
      value = value.gsub(/[^\d\.\-]/, '')
    end
    
    value = value.to_f
    if value.to_s == "NaN"
      return 0.0
    else
      return value
    end
  end
 
 
  # Cleans up strings for inclusion inside XML structure. Replaces problematic
  # characters with their XML escaped version.
  #
  #   Args:
  #      value: a string that we want escaped
  #
  #   Returns:
  #      string: an xml-friendly version suitable for parsing
  def clean_for_xml(value)
    value = value.to_s
    value = value.gsub(/\</,'<')
    value = value.gsub(/\>/,'>')
    value = value.gsub(/\"/,'"')
    value = value.gsub(/\'/,''')
    return value
  end

  # Clean up any point rounding weirdness for purposes of display andfilename.split('.').last
  # comparison.
  #
  #  Args:
  #     value  The value to convert.
  # Returns:
  #     The value as a string, containing number truncated to 6 decimal places,
  #     and stripped of trailing zeroes.
  def clean_number(value)
    if is_number(value)
      value = (((value.to_f*1000000.0).round) / 1000000.0).to_s
      value = value.gsub(/\.0$/, '')
    end
    return value
  end

  # Tells us whether a passed value contains a parsable number.
  #  Args:
  #     value  The value to check.
  # Returns:
  #     true if the value as a string contains nothing but digits and decimals
  #       and the negative sign.
  def is_number(value)
    return value.to_s =~ /^\-*\d+\.*\d*$/
  end


  # This method returns a named attribute from the DC dictionary. It looks
  # on the instance first... if no attribute is found there, it looks on
  # the definition next.
  #
  #   Args:
  #      entity: reference to the entity to get the value from
  #      name: string name of the attribute to return the value for
  #
  #   Returns:
  #      the value of the attribute, or nil if it can't determine that
  def get_attribute_value(entity,name)
    name = name.downcase

    if entity.typename == 'ComponentInstance'
      value = entity.get_attribute @dictionary_name, name
      if value == nil
        value = entity.definition.get_attribute(@dictionary_name, name)
      end
      return value
    elsif entity.typename == 'Group' || entity.typename == "Model" || 
      entity.typename == 'ComponentDefinition'
      return entity.get_attribute(@dictionary_name, name)
    else
      return nil
    end
  end
  
  # This methods loops through all the model entities and process them in case they are
  # either Components or Groups. Here more functionality can be added in case we want
  # to report about different entities.   
  #
  #   Args:
  #      list: beginning entities list used to communicate to this function 
  #      whether or not we are processing all the model entities or just the current 
  #      selection 
  #
  #   Returns:
  #      None
  def collect_attributes(list)
    n = 0
    # While there are still entities in the list array,  
    # determine their type and count them.
    while list != []    
      list.each do |item| 
      n +=1
      type = item.typename
      case type
        when "Group"
          # Add all the entities that are in that group into the group_list array.
          item.entities.each do |entity|  
            @group_list.push entity
          end
          #get the attributes and put them in the report string
          create_report_string(item, n)
          @group_list.delete(item)
        
        when "ComponentInstance"
          # You can call .entities on Component Definition, but not on 
          # Component Instance. You need to figure out which 
          # ComponentDefinition the instance belongs to.
          #(ComponentDefinition=ComponentInstance.definition)
          item.definition.entities.each do |entity|
            # Add all the entities in the component to the component_list.
            @component_list.push entity  
          end
          #get the attributes and put them in the report string
          create_report_string(item, n)
          #get rid of the item we have already examined in the list
          @component_list.delete(item)
      end
    end
    # Update the list array so it countains only the entities 
    # that were part of sub-groups and sub-arrays. Those 
    # sub-entities haven't been counted yet.
    list = @group_list + @component_list
    # Clear out the group and component lists so they're 
    # ready for the next level of sub-groups/components.
    @group_list.clear
    @component_list.clear
    end
  end

  # This method returns an ordered array of all attributes that are attached
  # to an entity. In the case of component instances, attributes attached to 
  # both the instance and the definition will be returned.
  #
  #   Args:
  #     attribute_entity: required, reference to the entity to report on
  #
  #   Returns:
  #     array of strings containing attribute names 
  def get_attributes_list(attribute_entity)
    list = {}
    if attribute_entity.attribute_dictionaries
      if attribute_entity.attribute_dictionaries[@dictionary_name]
        dictionary = attribute_entity.attribute_dictionaries[@dictionary_name]
        for key in dictionary.keys
          # Do not show attributes that start with _, as these are internal.
          if key[0..0] != '_'
            list[key] = true
          end
        end
      end
    end
    if attribute_entity.typename == "ComponentInstance"
      attribute_entity = attribute_entity.definition
      if attribute_entity.attribute_dictionaries
        if attribute_entity.attribute_dictionaries[@dictionary_name]
          dictionary = attribute_entity.attribute_dictionaries[@dictionary_name]
          for key in dictionary.keys
            if key[0..0] != '_' # Do not show attributes that start with _, as these are internal.
              list[key] = true
            end
          end
        end
      end
    end
    return list.keys
  end

  # This method populate the @report_data array with all the attributes attached
  # to an entity. In the case of component instances, attributes attached to 
  # both the instance and the definition will be returned.
  #
  #   Args:
  #     entity: Reference to the entity to report on
  #     number: Used to keep track of the times we have looped through the 
  #     model/selection entities. This can be modified to be used to keep 
  #     track of the depth of the reported on entities.  
  #
  #   Returns:
  #     a report string containing the collected attributes data 
  def create_report_string(entity, number)
    cell_data = []
      if entity.typename == "Model" || entity.typename == "Group" ||
        entity.typename == "ComponentInstance"
        # Add to list of attributes if we find some that aren't on the list.
        for attribute_name in get_attributes_list(entity)
          if @report_attribute_list.include?(attribute_name) == false
            if attribute_name[0..0] != '_'
              @title_array.push(attribute_name.upcase)
              @report_attribute_list.push attribute_name
            end
          end
        end
        
        # Try to get a nice, human-readable name for the entity.
        entity_name = entity.name
        if entity_name.length < 1
          if entity.typename == 'ComponentInstance'
            entity_name = entity.definition.name
          elsif entity.typename == 'Model'
            entity_name = 'Model'
          else
            entity_name = 'Unnamed Part'
          end
        end

        # Remember those "hard-coded" columns from the very start of the report?
        # Here is where we manually populate them with explicit ruby calls,
        # since they're not strictly "attributes" that we're wanting to see.
        cell_data.push(number.to_s)
        if entity.typename == "ComponentInstance"
          cell_data.push(entity.definition.name)
        else
          cell_data.push('-')
        end
        cell_data.push(entity.description)
        cell_data.push(entity.layer.name)

        # Add the attributes to our report results.
        for attribute_name in @report_attribute_list
          value = get_attribute_value(entity,attribute_name)
          if value.kind_of? Float
            if value.to_s.include?('e-')
              value = 0.0
            else 
              value = clean_number(value)
            end
          end
          if value == nil
            value = ""
          end
          if value == '0.0'
            value = ""
          end

          cell_data.push(value)

          # Store running totals of each column by forcing every value into a
          # float and storing it. (That means that string attributes will
          # typically have "totals" of 0.0, but that's reasonable from a
          # programmer's perspective.)
          if @totals_by_att_name[attribute_name.upcase] == nil
            @totals_by_att_name[attribute_name.upcase] = 0.0
          end
          @totals_by_att_name[attribute_name.upcase] = 
            @totals_by_att_name[attribute_name.upcase] +
            to_number(value).to_f
          
        end
        # Take our array of attribute values and push it onto our assembled
        # report data.
        @report_data.push(cell_data)  
    end
  end

  
  # This method format the @report_data string assembled in create_report_string
  # according to the specified file type in @file_type into the @report_string
  #   Args:
  #   	None       
  #   Returns:
  #      None
  def write_report_string
  
      # Create the initial string that is our report.
      @report_string = @doc_start

      # Append the "title row" of the report, which is a series of cells that
      # contain the ordered names from @title_array.
      @report_string += @row_start + @cell_start + @title_array.join(@cell_mid) +
        @cell_end + @row_end

      # The longest row in the report is guaranteed to be the last row in the
      # report, just because of how we built them. So grab that length now so
      # can can properly append "empty" cells to any records that don't have
      # all of the attributes.
      if @report_data.last.nil?
        UI.messagebox "No Components or Groups in the selection"
        return -1
      else
        longest_row_length = @report_data.last.length
      end

      # Let's generate a record for the end of the report that contains the
      # "totals" of any column that appears to be numeric in nature.
      totals_row = []
      for att_name in @title_array
        total = clean_number(@totals_by_att_name[att_name]).to_f
        if total == 0.0
          total = '-'  # This is the string that is put into "empty" cells.
        end
        totals_row.push total
      end
      totals_row[0] = 'TOTALS'
      @report_data.push totals_row

      # Now loop across the assembled @report_data and build up our report.
      for cell_data in @report_data
        @report_string += @row_start
        for i in 0..(longest_row_length-1)
          value = cell_data[i]
          @report_string += @cell_start
          if @filetype == "csv"
            value = value.to_s
            value = value.gsub(/\"/,'""')
            value = '"' + value + '"'
            @report_string += value
          else # default to html output.
            @report_string += clean_for_xml(value)
          end
          @report_string += @cell_end
        end
        @report_string += @row_end
      end

      @report_string += @doc_end

      # Clean up the report data variables to release memory.
      @report_attribute_list = nil
      @title_array = nil
      @report_data = nil
      @totals_by_att_name = nil
  end


  def generate_attributes_report(filename, entities_list)
  
    # Start an operation so everything performs faster.
    Sketchup.active_model.start_operation 'Generate Report', true
  
    # initialization of all the class variables used
    set_up(filename)
    
    #collect all the attributes in the selection or in the model
    collect_attributes(entities_list)  
    
    # This check is to capture the case in which the selection for which we were 
    # generating the report did not contain either a Group r a Component 
    if write_report_string == -1
      return
    end

    # Open a save dialog on the last known path, (passing nil as the save path 
    # does that automatically.)
    path = UI.savepanel "Save Report", nil, @filename
    if (path and path.split('.').last == @filetype)
      begin
        file = File.new(path, "w")
        file.print @report_string 
      rescue 
        msg = "There was an error saving your report.\n" +
          "Please make sure it is not open in any other software " +
          "and try again."
      ensure
        file.close
      end        
      
    elsif path.nil == false
      UI.messagebox "You Have changed the filetype in the save dialog, please try again."      
    end

    # All done, so commit the operation.
    Sketchup.active_model.commit_operation

  end

end


if( not $attribute_reporter_loaded )

  attr_reporter = AttrReporter.new

  # Set up some UI hooks.
  plugins_menu = UI.menu "Plugins"
  plugins_menu.add_item("Generate Model Attribute Report as HTML") {
    attr_reporter.generate_attributes_report("report.html", Sketchup.active_model.entities)
  }
  plugins_menu.add_item("Generate Model Attribute Report as CSV") {
    attr_reporter.generate_attributes_report("report.csv", Sketchup.active_model.entities)
  }

  # This allow you to get the reporting based on a selection and not for the whole model
  # if the selection does not contain a component or group it will pop an error message
  UI.add_context_menu_handler do |context_menu| 
    context_menu.add_separator
      context_menu.add_item('Generate Selection Attributes Report -> HTML') do
        attr_reporter.generate_attributes_report("report.html", Sketchup.active_model.selection)
      end
      context_menu.add_item('Generate Selection Attributes Report -> CSV') do
        attr_reporter.generate_attributes_report("report.csv", Sketchup.active_model.selection)
      end
  end

  $attribute_reporter_loaded = true

end