top of page
TrimbleR-Horiz-RGB-Blue.jpg
SketchUp-Horizontal-RGB-120pxl_0.png

Making Objects Parametric

We've already looked at how to draw a complex object in SketchUp from user input using the Ruby API, but to take full advantage of this, you want to be able to go back and change those input parameters.

This article covers a more advanced technique and could be useful for quite experienced developers, but hopefully made simple enough that also relative beginners can follow along.

A Dumb Table

To start with, we have the code to draw a table from the previous article, which should look something like this. If you've customized your code to e.g. use tapered legs and additional user inputs, that's okay; the overall steps are the same but you need to make some minor tweaks.

def draw_box(entities, origin, width, depth, height)
  face = entities.add_face([
    origin,
    [origin.x + width, origin.y, origin.z],
    [origin.x + width, origin.y + depth, origin.z],
    [origin.x, origin.y + depth, origin.z]
  ])
  face.reverse! unless face.normal.samedirection?(Z_AXIS)
  face.pushpull(height)
end

def draw_table(entities, width, depth, height, board_thickness, leg_thickness,
               leg_inset)
  table = entities.add_group
  leg_height = height - board_thickness
  
  # Board
  board = table.entities.add_group
  board.transformation = Geom::Transformation.new([00, leg_height])
  draw_box(board.entities, [000], width, depth, board_thickness)
  
  # Legs
  first_leg = table.entities.add_group
  first_leg.transformation =
    Geom::Transformation.new([leg_inset, leg_inset, 0])
  draw_box(first_leg.entities, [-leg_thickness / 2, -leg_thickness / 20],
           leg_thickness, leg_thickness, leg_height)
  table.entities.add_instance(
    first_leg.definition,
    Geom::Transformation.new([width - leg_inset, leg_inset, 0])
  )
  table.entities.add_instance(
    first_leg.definition,
    Geom::Transformation.new([leg_inset, depth - leg_inset, 0])
  )
  table.entities.add_instance(
    first_leg.definition,
    Geom::Transformation.new([width - leg_inset, depth - leg_inset, 0])
  )
end

def draw_table_ui
  width = 1.7.m
  depth = 80.cm
  height = 72.cm
  board_thickness = 20.mm
  leg_thickness = 30.mm
  leg_inset = 50.mm
  
  inputs = UI.inputbox(
    ["Width""Depth""Height""Board Thickness""Leg Thickness""Leg Inset"],
    [width, depth, height, board_thickness, leg_thickness, leg_inset],
    "Draw Table"
  )
  return unless inputs
  
  width, depth, height, board_thickness, leg_thickness, leg_inset = inputs
  
  model = Sketchup.active_model
  model.start_operation("Draw Table"true)
  draw_table(model.active_entities, width, depth, height, board_thickness,
             leg_thickness, leg_inset)
  model.commit_operation
end

menu = UI.menu("Extensions")
menu.add_item("Draw Table") { draw_table_ui }

This code allows us to draw a table, but if we later want to change the input parameters, we need to draw a whole new table. The previous input isn't stored anywhere so the user has to manually enter the same values again, and if they get one value wrong, they have to restart and rewrite all inputs, risking getting another one wrong this time. This is not very practical!

A Smart Table

For a better user experience, we can save the input parameters and allow the same table to be redrawn at any time.

Refactor

To begin, we refactor the code to be easier to build upon. It still does the same thing, just structured slightly differently. These changes make it easier to later hold on to the user configurations for each table.

module Shapes
  def self.draw_box(entities, origin, width, depth, height)
    face = entities.add_face([
      origin,
      [origin.x + width, origin.y, origin.z],
      [origin.x + width, origin.y + depth, origin.z],
      [origin.x, origin.y + depth, origin.z]
    ])
    face.reverse! unless face.normal.samedirection?(Z_AXIS)
    face.pushpull(height)
  end
end

class Table
  PARAM_NAMES = ["Width""Depth""Height""Board Thickness""Leg Thickness",
                 "Leg Inset"]
  DEFAULTS = [1.7.m80.cm72.cm20.mm30.mm50.mm]

  def initialize
    @width@depth@height@board_thickness@leg_thickness@leg_inset =
      DEFAULTS
  end

  def edit_parameters
    inputs = UI.inputbox(
      PARAM_NAMES,
      [@width@depth@height@board_thickness@leg_thickness@leg_inset],
      "Draw Table"
    )
    return unless inputs
    
    @width@depth@height@board_thickness@leg_thickness@leg_inset =
      inputs
    
    model = Sketchup.active_model
    model.start_operation("Draw Table"true)
    draw(model.active_entities)
    model.commit_operation
  end
  
  def draw(entities)
    group = entities.add_group
    leg_height = @height - @board_thickness
    
    # Board
    board = group .entities.add_group
    board.transformation = Geom::Transformation.new([00, leg_height])
    Shapes.draw_box(board.entities, [000], @width@depth@board_thickness)
    
    # Legs
    first_leg = group.entities.add_group
    first_leg.transformation =
      Geom::Transformation.new([@leg_inset@leg_inset0])
    Shapes.draw_box(first_leg.entities,
                    [-@leg_thickness / 2, -@leg_thickness / 20],
                    @leg_thickness@leg_thickness, leg_height)
    group.entities.add_instance(
      first_leg.definition,
      Geom::Transformation.new([@width - @leg_inset@leg_inset0])
    )
    group.entities.add_instance(
      first_leg.definition,
      Geom::Transformation.new([@leg_inset@depth - @leg_inset0])
    )
    group.entities.add_instance(
      first_leg.definition,
      Geom::Transformation.new([@width - @leg_inset@depth - @leg_inset0])
    )
  end
end

menu = UI.menu("Extensions")
menu.add_item("Draw Table") { Table.new.edit_parameters }

Here all the behaviors and data defining a table is wrapped into a new custom Table class. The generic drawing that isn't necessarily used only by the table can be extracted to a separate Shapes module. This separation allows us to keep each part of the software smaller and to focus on one thing at a time when coding, without being distracted by everything else.

A class is a way to define the common characteristics for a type of things. There can be any number of tables with different measurements, but they all follow the same pattern. Within the class we define the table-specific methods, such as drawing and the UI for editing the parameters.

When we want to create a new table, also known as an instance of the Table class, we call the Table.new method. In Ruby this triggers the initialize constructor method. Here we can set up the table object with the default parameters. These default values are stored as a constant on the top of the Table class. Once a Table object is created, it holds on to its parameters using instance variables, variables prefixed with a @ sign. Instance variables are accessible from the various instance methods of the Table and are kept in memory for as long as the Table is.

Shapes is defined as a module. Modules are similar to classes, but you can't create instances of them. In this example we are not interested in keeping track of the dimensions we give our boxes, we just calculate them on the fly when we need to draw a box.

If a method definition is prefixed with self., it's a class method that runs on the class as a whole. Otherwise it's an instance method that runs an individual object of that class. draw_box is called on the Shapes module as a whole whereas draw or edit_parameters is called on an individual table.

Editable Table

At this point there's no link between the custom Table object and the group representing a table in the SketchUp model. Once we've drawn our table, we can't get back to the parameters to edit and redraw it.

Also our Table object isn't persistent. Unlike a Sketchup::Group or Sketchup::Face object, our custom Table object isn't stored with the SketchUp model. We need a way to serialize (save) the object so we can deserialize it (recreate) next time we want to use it.

To address this, we tweak the Table class to also track the group the table is drawn to. The initialize constructor method is altered to also be able to recreate a table from a group. We add methods to read and write the parameters to the group. Whenever we draw the table, we also save the parameters and whenever we recreate an existing table, we read the parameters back. Lastly, we add a class method to Table to test if an entity in the SketchUp model represents a table.

class Table
  PARAM_NAMES = ["Width""Depth""Height""Board Thickness""Leg Thickness",
                 "Leg Inset"]
  DEFAULTS = [1.7.m80.cm72.cm20.mm30.mm50.mm]
  
  DICTIONARY = "jane_doe_custom_table"
  
  def self.table?(entity)
    entity.is_a?(Sketchup::Group) && entity.attribute_dictionary(DICTIONARY)
  end

  def initialize(group = nil)
    @group = group
    if group
      read_parameters
    else
      @width@depth@height@board_thickness@leg_thickness@leg_inset =
        DEFAULTS
    end
  end
  
  def read_parameters
    @width           = @group.get_attribute(DICTIONARY"width")
    @depth           = @group.get_attribute(DICTIONARY"depth")
    @height          = @group.get_attribute(DICTIONARY"height")
    @board_thickness = @group.get_attribute(DICTIONARY"board_thickness")
    @leg_thickness   = @group.get_attribute(DICTIONARY"leg_thickness")
    @leg_inset       = @group.get_attribute(DICTIONARY"leg_inset")
  end
  
  def write_parameters
    @group.set_attribute(DICTIONARY"width"@width)
    @group.set_attribute(DICTIONARY"depth"@depth)
    @group.set_attribute(DICTIONARY"height"@height)
    @group.set_attribute(DICTIONARY"board_thickness"@board_thickness)
    @group.set_attribute(DICTIONARY"leg_thickness"@leg_thickness)
    @group.set_attribute(DICTIONARY"leg_inset"@leg_inset)
  end

  def edit_parameters
    inputs = UI.inputbox(
      PARAM_NAMES,
      [@width@depth@height@board_thickness@leg_thickness@leg_inset],
      "Draw Table"
    )
    return unless inputs
    
    @width@depth@height@board_thickness@leg_thickness@leg_inset =
      inputs
    
    model = Sketchup.active_model
    model.start_operation("Draw Table"true)
    draw(model.active_entities)
    model.commit_operation
  end
  
  def draw(parent_entities = nil)
    if @group
      @group.entities.clear!
    else
      @group = parent_entities.add_group
    end
    write_parameters
    
    leg_height = @height - @board_thickness
    
    # Board
    board = @group.entities.add_group
    board.transformation = Geom::Transformation.new([00, leg_height])
    Shapes.draw_box(board.entities, [000], @width@depth@board_thickness)
    
    # Legs
    first_leg = @group.entities.add_group
    first_leg.transformation =
      Geom::Transformation.new([@leg_inset@leg_inset0])
    Shapes.draw_box(first_leg.entities,
                    [-@leg_thickness / 2, -@leg_thickness / 20],
                    @leg_thickness@leg_thickness, leg_height)
    @group.entities.add_instance(
      first_leg.definition,
      Geom::Transformation.new([@width - @leg_inset@leg_inset0])
    )
    @group.entities.add_instance(
      first_leg.definition,
      Geom::Transformation.new([@leg_inset@depth - @leg_inset0])
    )
    @group.entities.add_instance(
      first_leg.definition,
      Geom::Transformation.new([@width - @leg_inset@depth - @leg_inset0])
    )
  end
end

menu = UI.menu("Extensions")
menu.add_item("Draw Table") { Table.new.edit_parameters }

With all of this, we can add a context menu entry for parametrically editing a table.

UI.add_context_menu_handler do |context_menu|
  selection = Sketchup.active_model.selection
  next unless selection.size == 1
  next unless Table.table?(selection[0])
    
  context_menu.add_separator
  context_menu.add_item("Edit Table"do
    Table.new(selection[0]).edit_parameters
  end
end

Now you can finally right click a table to edit it!

The menu entry is only added if exactly one entity is selected in the SketchUpp model, and that entity represents a table. next is a bit similar to return from Writing Your First Code. However, here we want to break out of a block, not the whole method.

When adding entries to the context menu, always add a separator first. Otherwise it looks like your menu entries are grouped together with whatever happens to be the entries above.

Accessible Table

While not necessary for this use case, we could add attribute accessors so we can edit the table parameters from the outside.

class Table
  attr_accessor :width:depth:height:board_thickness:leg_thickness,
    :leg_inset

  # ...
end

This allows us to play with the table from the Ruby Console.

table = Table.new
table.width = 2.m
table.height = 90.cm
table.draw(Sketchup.active_model.active_entities)

table.width = 1.m
table.draw(Sketchup.active_model.active_entities)

This also allows other parts of your application to create and configure tables. Perhaps there could be a Table tool that lets you pick corner points similar to Rectangle tool? While not very meaningful with tables, this is highly useful for parametric walls, trusses, slabs and other building elements that are tailored to fit into each other.

This outside parameter access would also be useful for creating nested parametric elements. Maybe you have one button to create a complete building from outer overall measurements and roof pitch, but if you later explode it you can interact parametrically with individual trusses and stud walls.

A SketchUppy Table!

At this point we can create and later reconfigure our custom parametric tables, but they don't behave very SketchUppy. Let's fix that!

Double Click Editing

The primary way to edit a complex object in SketchUp is to double click it to access its internal geometry. However, for our custom tables we don't really want the user to go inside and make manual changes. The manual editing using SketchUp tools and the parametric editing are two clashing paradigms, and any manual changes will be lost if the user later changes the parameters and the table redraws.

We could display a warning whenever the user is about to make manual edits to the table, but a better approach is to automatically steer them into the parametric editing mode. The user doesn't double click a component for the purpose of going inside of it, but to edit it. Going into the component is just the means, not the purpose. At a higher level, our edit_parameters method does what the user wants to do when double clicking.

To achieve this, we can intercept the group being opened, immediately close it and bring up our custom UI, using an observer.

class OpenTableObserver
  # Called by SketchUp when you enter or exit groups and components
  def onActivePathChanged(model)
     # Active path is nil when in model root, no empty array as you might
     # expect.
     return unless model.active_path
     
     # -1 is the last element in the array
     inner_child = model.active_path[-1]
     return unless Table.table?(inner_child)
     
     # Go back out of group
     Sketchup.undo
     
     # Open parametric editing instead
     Table.new(inner_child).edit_parameters
  end
 
  def attach(observee)
    # Remove observer before attaching, to avoid attaching same observer
    # multiple times. 
    observee.remove_observer(self)
    observee.add_observer(self)
  end
  alias onNewModel attach
  alias onOpenModel attach
end

observer = OpenTableObserver.new
observer.attach(Sketchup)
observer.attach(Sketchup.active_model)

An Observer is a way to trigger your code from various events in SketchUp, not just from the user calling it directly from a menu or toolbar. They can be used to make your extension feel tighter integrated into SketchUp. Most of this code is just the boilerplate to attach the observer on each new model the user opens or creates. The relevant part is onActivePathChanged.

If the user wants to do manual editing, such as making tweaks not supported by our parametric model, they can explode the group to access its internal parts. This is analogous to exploding an Image entity in SketchUp to access its internal face and edges.

Scale Tool Integration

If you want to change the overall size of an object in SketchUp, Scale tool is the typical way to do it. If you try this on the table now, it just stretches. Legs get thicker in one direction and the leg inset gets inconsistent. Well, that's annoying. Luckily this too can be fixed!

class Table
  # ...

  def on_resize
    x_scale, y_scale, z_scale =
      TransformationHelper.extract_scaling(@group.transformation)
    
    # No need to redraw if we didn't resize the table
    return if x_scale == 1 && y_scale == 1 && z_scale == 1
    
    # Calling to_l revert back to the Length class after multiplication
    # changes values to Float. this affects how values are displayed in
    # UI.inputbox.
    @width = (@width * x_scale).to_l
    @depth = (@depth * y_scale).to_l
    @height = (@height * z_scale).to_l
      
    @group.transformation =
      TransformationHelper.reset_scaling(@group.transformation)  
    draw
  end
end
module TransformationHelper
  # @param [Geom::Transformation]
  # @return [Array<Numeric, Numeric, Numeric>]
  def self.extract_scaling(transformation)
    # Uniform scaling may be expressed by the last element in the transformation
    # matrix.
    scale = transformation.to_a[15]
    
    # Axial scaling is represented by the length of each coordinate axis,
    # defined by the first 3 values of the first 3 rows.
    [
      Geom::Vector3d.new(transformation.to_a.values_at(0..2)).length / scale,
      Geom::Vector3d.new(transformation.to_a.values_at(4..6)).length / scale,
      Geom::Vector3d.new(transformation.to_a.values_at(8..10)).length / scale
    ]
  end
  
  # @param [Geom::Transformation]
  # @return [Geom::Transformation]
  def self.reset_scaling(transformation)
    Geom::Transformation.axes(
      transformation.origin,
      transformation.xaxis.normalize,
      transformation.yaxis.normalize,
      transformation.zaxis.normalize,
    )
  end
end

This method on Table is expected to be called just after the table is resized with Scale tool. In SketchUp, Scale tool changes the selected entity, in this case a group, not its inner content. The scaling is represented by the group's transformation matrix, while its content remains the same in the internal coordinate system.

What we want is to read the new size and instead draw the content using it as the new parameters. To do this, we start by extracting the scaling component from the transformation matrix and multiply it into our parameters. Then we remove the scaling from the transformation, so we don't have the scaling applied twice, while retaining any translation or rotation. Lastly we redraw the table to the new configuration.

The lower level code for extracting and removing scaling from a transformation matrix is kept in a separate TransformationHelper module, to keep Table focused on the table-specific behaviors.

This code can be manually tested by first resizing a table with Scale tool and then triggering on_resize from the Ruby console.

Table.new(Sketchup.active_model.selection.first).on_resize

But we want this to happen automatically each time the user scales a table. For this we use another observer.

class ScaleTableObserver
  SCALE_TOOL_ID = 21236 
  
  # Triggered by SketchUp when a tool changes state
  def onToolStateChanged(tools, _tool_name, tool_id, tool_state)
    return unless tool_id == SCALE_TOOL_ID
    return unless tool_state == 0
    
    # Scale tool has entered its initial state.
    # Either it's been activated, reset or finished scaling something.
    
    table_groups = tools.model.selection.select { |e| Table.table?(e) }
    return if table_groups.empty?
    
    tools.model.start_operation("_RESIZE_TABLES"truefalsetrue)
    table_groups.each { |g| Table.new(g).on_resize }
    tools.model.commit_operation
  end

  def attach(observee)
    # Remove observer before attaching, to avoid attaching same observer
    # multiple times. 
    observee.remove_observer(self)
    observee.add_observer(self)
  end
  
  def onNewModel(model)
    attach(model.tools)
  end
  alias onOpenModel onNewModel
end

observer = ScaleTableObserver.new
observer.attach(Sketchup)
observer.attach(Sketchup.active_model.tools)

This observer triggers whenever Scale tool goes into its initial state, which happens when the tool is activated, cancelled or finishes scaling something. When this happens we check if the selection contains any tables. If so, we start an operation, call on_resize on each table and commit the operation.

The operation is made transparent to the previous operation using the 4th argument. This means undoing the redraw also undoes the scaling operation that triggered it. To the user there is just one operation, Scaling, and the table magically just resizes correctly as opposed to merely stretching.

When an operation is transparent to the previous operation, its operation name is not visible to the user. We could pass an empty string but a name could also help anyone maintaining the code to understand what it does. Avoiding the usual title case is a way to hint that this string is not user-facing.

Now you have a happy SketchUppy table that is easy to interact with!

Further Reading

SketchUp Extension UX Guidelines

bottom of page