[Code] Example: Saving Iso views to PNG image files

Continuing the discussion from Waiting for Sketchup.send_action to complete before continuing:

And here is the working example with no ViewObserver … no dedicated scene pages, … restoring the previous view, … no actual modification of the model. (You can close an unmodified model after writing images with no save prompt messagebox.)

This example is SketchUp 2015+, as it uses UI.select_directory method.

(But I did get it to work for SU2014 when I did not need to select a directory, ie, opened a model previously saved that has a @model.path. IF it is tried on SU2014 on an unsaved model a NoMethodError will result.)

save_view_as_png_no_observer_demo.rb (11.8 KB)


Example uses the concepts of azimuth and elevation (as in locating the position of a satellite):


The file looks like this:

# encoding: UTF-8
#
# An example of saving the standard iso views to image files.
# This example does not use (or need) a ViewObserver class.
#
# It teaches: 
# * Creating a bounding box, and iterating the model's 
#     entities to add drawingelement object bounds to it.
# * Use of transformations to locate the camera eye point
#     and up vectors, ie, azimuth and elevation.
# * Saving and restoring the view camera.
# * Saving layer visibility states, hiding layers whose name
#     contians a certain string, and restoring the previous
#     layer visibility states.
# * Writing out the image files.
# * Creating a context menu of image write commands.
#
# This example carries NO WARRANTY. It is example code only.
# No copyright implied nor intended.
#
# Change top level module name to something unique for use.
# Submodule may be renamed to satisfy personal desires.
#
module Author; end
module Author::WriteViewImagesNoObserver

  extend self

  VERSION ||= '1.0.0'

  EXT     ||= 'png'
  COMPANY ||= 'CompanyName'

  # Used for Run Once block condition (bottom of module):
  @@loaded ||= false

  # Module variables that can be optionally changed:
  @@iso_azimuth   ||= Hash[ :NE,45, :NW,315, :SE,135, :SW,225 ]
  @@iso_elevation ||= 20
  @@iso_viewpoint ||= :SE
  @@cline_bounds  ||= false # include finite clines in image bounds?
  @@hide_layers   ||= true
  @@hide_toggle   ||= MF_CHECKED

  def camera_bounds(clines= @@cline_bounds)
    # Start with a new empty bounding box:
    bb = Geom::BoundingBox::new
    # Iterate the model entities collection:
    @model.entities.each {|e|
      # Skip object unless it's a Drawingelement subclass object.
      # If so, entity will respond to .bounds(), .layer() and .visible?()
      next unless e.is_a?(Sketchup::Drawingelement)
      # Deal with Construction Lines:
      if e.is_a?(Sketchup::ConstructionLine)
        next if !clines
        # Always avoid infinite clines !
        next if ( e.start.nil? || e.end.nil? )
      end
      # Add the entity bounds to our bounding box,
      #   if it's visible and on a visible layer:
      bb.add(e.bounds) if e.visible? && e.layer.visible?
    }
    bb # return the bounding box object
  end

  def camera_iso_setup( corner )
  #
  # Setup the Iso camera eye and target points, and up vector.
  # Note, in SketchUp the Y axis points North (by default.)
  #
    # Get bounding box of visible entities:
    bb = camera_bounds()
    target = bb.center
    # Temporarily locate the eye point North of the entity
    # bounds center (target), on the same plane, at a distance of
    # the bounds depth:
    eye = target.transform([ 0, bb.depth, 0 ])
    # Temporarily create the camera up vector as a clone of Z_AXIS:
    up = Z_AXIS.clone
    # Rotate eye point @@iso_elevation degrees above target plane,
    # about a vector pointing from target, parallel to the X axis.
    # Imagine that the rotatinal axis vector of the clock hands is
    # pointing out away from the clock face, and you face the clock.
    # Clockwise will be negative. Anti-clockwise positive.
    t1 = Geom::Transformation::rotation(
      target, X_AXIS, @@iso_elevation.degrees
    )
    eye.transform!(t1)
    # Transform the up vector so it remains perpendicular to the
    # eye -> target directional vector:
    up.transform!(t1)
    # Now, transform the elevated eye point at North position,
    # @@iso_azimuth[corner] degrees (clockwise) to the proper
    # azimuth, about the reversed Z axis of the target point.
    # We must use a reverse vector because we want to rotate in 
    # clockwise direction but keeping the angle positive so that
    # the rotational degrees match the standard compass degrees.
    # If we did not reverse the Z vector (and the clock face,)
    # positive angles would rotate the eye point opposite that of
    # of a standard compass bearing. (We could also just negate
    # the 3rd argument of the :rotation class method and use the
    # Z_AXIS as is, pointing upward.)
    t2 = Geom::Transformation::rotation(
      target, Z_AXIS.reverse, @@iso_azimuth[corner].degrees
    )
    eye.transform!(t2)
    # Transform the up vector to match:
    up.transform!(t2)
    # Return the camera properties:
    [ eye, target, up ]
  end

  def camera_top_setup()
    # Get bounding box of visible entities:
    bb = camera_bounds()
    # The target will be the center of the entities:
    target = bb.center
    # The eye will be directly above the target
    # by a distance equal to the bounds height:
    eye = target.transform([ 0, 0, bb.height ])
    # The camera up vector will always point North:
    up  = Y_AXIS.clone
    # Return the camera properties:
    [ eye, target, up ]
  end

  def camera_setup( viewname, viewpt )
    if viewname.to_s.downcase == 'top'
      camera_top_setup()
    elsif viewname.to_s.downcase == 'iso'
      camera_iso_setup(viewpt)
    else
      puts "ERROR: #<#{Module::nesting[0].name}:camera_setup():"<<
      " Unknown view name argument (\"#{viewname.to_s}\").>"
    end
  end

  def get_filename( viewarg, viewpt )
    path = File.dirname(@model.path)
    # Use downcase() to create new string object
    #   so the original is NOT changed. Ie, Ruby
    # passes arguments by reference, NOT by value.
    # The first argument object is used unchanged
    # for other subsequent method calls.
    viewname = viewarg.to_s.downcase
    if viewname == 'iso'
      viewname << "_" << viewpt.to_s.downcase
    end
    if path.empty?
      filename = "UNTITLED_#{viewname}.#{EXT}"
    else
      filename = "#{@model.title}_#{viewname}.#{EXT}"
    end
    [ path, filename ]
  end

  def get_filepath( path, filename )
    # Returns nil if user cancels the dialog.
    @filepath = UI.savepanel(
      'Choose file save location ...',
      path,
      filename
    )
  end

  def hide_layers()
  # Remember layer visibility states, then hide non-company layers.
    #
    # Hash to remember previous layer states:
    @layers = {}
    # Remember the current active layer:
    @active = @model.active_layer
    layer_set = @model.layers
    # For safety sake, we'll switch to "Layer0":
    @model.active_layer= layer_set['Layer0']
    # Iterate the model layers collection:
    layer_set.each {|layer|
      # Save each layer visibility state in the @layers hash:
      @layers[layer.name]= layer.visible?
      # Switch off non-company layers ...
      # BUT, "Layer0" must always be visible!
      next if layer.name == 'Layer0'
      # Now, hide layers whose name does not contain company substring:
      layer.visible= false if layer.name !~ /#{COMPANY}/
    }
  end

  def hide_layers_toggle()
    @@hide_layers = !@@hide_layers
    @@hide_toggle =( @@hide_layers ? MF_CHECKED : MF_UNCHECKED )
  end

  def restore_layers()
    layer_set = @model.layers
    # Iterate the saved layer states in the @layers hash:
    @layers.each {|name,state|
      # Restore each layer visibility state:
      layer_set[name].visible= state
    }
    # Restore the previous active layer:
    @model.active_layer= @active
  end

  def remember_view()
    # Reference the camera object for the active view:
    cam = @model.active_view.camera
    # Create a clone using the active camera properties:
    @camera = Sketchup::Camera::new(
      cam.eye, cam.target, cam.up, cam.perspective?, cam.fov
    )
  end

  def restore_view()
    # Set the model active view to use the cloned @camera:
    @model.active_view.camera= @camera
  end

  def set_view( viewname, viewpt )
    # The original view camera should already be cloned
    # to the @camera reference. See: remember_view()
    view = @model.active_view
    # Set the camera:
    cam  = view.camera
    eye, target, up = camera_setup(viewname,viewpt)
    cam.set( eye, target, up )
    cam.perspective=( viewname.to_s.downcase == 'iso' )
    # Zoom to visible entities:
    view.zoom(@model.entities)
  end

  def save_all_iso_views()
    # Set a reference to the active model (used by many methods.)
    @model = Sketchup.active_model
    if @model.path.empty?
      # Prompt for a directory:
      savepath = UI.select_directory(
        title: '',
        directory: '',
        select_multiple: false
      )
      if !savepath || savepath.empty? # empty String or Array
        puts "#{Module::nesting[0].name}: User cancelled directory panel."
        puts "Save all Iso view images aborted."
        return false
      end
    else
      savepath = File.dirname(@model.path)
    end
    # 1 time before loop:
    puts "\nSave all Iso view images to:\n  \"#{savepath}\""
    viewname = 'iso'
    remember_view()
    hide_layers() if @@hide_layers
    # For each of the azimuth viewpoints:
    for viewpt in @@iso_azimuth.keys()
      filename = get_filename(viewname,viewpt).last
      @filepath = File.join(savepath,filename)
      puts "Writing file: #{filename}"
      # Set the view:
      set_view(viewname,viewpt)
      # Write the view to image file:
      write_view_file()
    end
    # 1 time after loop, Restore the original view and layers:
    restore_view()
    restore_layers() if @@hide_layers
    puts "Finsihed writing all iso view files."
  end

  def save_view( viewname, viewpt = @@iso_viewpoint )
    # Set a reference to the active model (used by many methods.)
    @model = Sketchup.active_model
    path, filename = get_filename(viewname,viewpt)
    if get_filepath(path,filename) # nil if user cancelled
      remember_view()
      hide_layers() if @@hide_layers
      # Set the view:
      set_view(viewname,viewpt)
      # Write the view to image file:
      puts "Writing file: #{filename}"
      write_view_file()
      # Restore the previous view:
      restore_view()
      # Restore the previous layer states:
      restore_layers() if @@hide_layers
      puts "Finsihed writing view to image file."
    else
      puts "#{Module::nesting[0].name}: User cancelled file save panel."
      puts "#{viewname.capitalize} View image file save aborted."
    end
  end

  def write_view_file()
    # Switch to the save directory and perform operations:
    Dir::chdir(File.dirname(@filepath)) {
      file = File.basename(@filepath)
      # Delete image file if it already exists.
      # An overwrite confirmation box may be displayed to the user, if
      # SketchUp view#write_image() thinks that the file already exists.
      if File.exist?(file)
        File.delete(file)
        sleep 2 # Let the OS catch up.
      end
      # Write the image file:
      written = Sketchup.active_model.active_view.write_image({
        :filename    => @filepath,
        :antialias   => true,
        :transparent => true
      })
      # When the block ends, Ruby switches back to previous directory.
    }
  rescue => error
    puts "Error in write_view_file()"
    puts error.inspect
  end

  unless @@loaded # Run this block ONCE:
    #
    UI.add_context_menu_handler {|popup|
      submenu = popup.add_submenu('Save View Image')
      submenu.add_item('Default Iso View') { save_view('iso',@@iso_viewpoint) }
      submenu.add_item('Save All Iso Views') { save_all_iso_views() }
      toggle = submenu.add_item('Hide Non-Company Layers') { hide_layers_toggle() }
      submenu.set_validation_proc(toggle) { @@hide_toggle }
      submenu.add_separator
      submenu.add_item('NorthEast Iso View') { save_view('iso',:NE) }
      submenu.add_item('NorthWest Iso View') { save_view('iso',:NW) }
      submenu.add_item('SouthEast Iso View') { save_view('iso',:SE) }
      submenu.add_item('SouthWest Iso View') { save_view('iso',:SW) }
      submenu.add_separator
      submenu.add_item('Save Top View') { save_view('top') }
    }
    #
    @@loaded = true
    #
  end

end
3 Likes

@DanRathbun - I’m a little stuck about what exactly the “eye” is for a Camera. Could you provide a little clarification for me about what the eye is and what it tells the Camera? Could you also explain to me why we want to do what you are doing in the quoted code above? I read through the whole file, but I couldn’t quite grasp this part of it. Thanks!

Well, from the API documentation on the Sketchup::Camera class …

The Camera class contains methods for creating and manipulating a camera. The camera in SketchUp is the “point of view” from which you look at the model.

Since the text is personifying this action (using the pronoun you,) they decided to also use the term “eye”, since this is what you look at things with.

In actuality, (code-wise) the “eye” is a property of a camera object. (Everything in Ruby is an object.)
In Ruby properties are also sometimes referred to as attributes, which are implemented in pure Ruby objects by wrapping instance variables. (FYI, these pure Ruby attributes are not the same as data that SketchUp stores in Attribute Dictionaries.) But SketchUp API objects are C++ side objects with Ruby wrappers, that are exposed via getter and setter methods.

So we look at the API documentation for the Camera class, and it’s #eye() instance “getter” method:
Sketchup::Camera#eye()
and we see that the description …

The eye method is used to retrieve the eye Point3d object for the Camera.

This indicates it returns a Geom::Point3d object.

Basically, this is the point (expressed as 3 coordinates, x, y, z,) in 3D space of a viewing camera.
(I’m not sure if we would consider it to be the center of the lens, or the camera’s focal point. I lean toward the latter.)


But the API camera object’s do not have individual setter instance methods for all of the basic orientation properties. Instead the API coders decided to implement one “setter” method to set all 5 at once.
Ie: Sketchup::Camera#set()

The target of the camera, is the point in 3D space (Geom::Point3d object,) that the camera is looking at.
This sets up a vector (Geom::Vector3d) object, from the eye point, to the target point, called the direction.
Ie: Sketchup::Camera#direction()

Lastly, the camera has a up vector, from the eye point, perpendicular to the camera’s directional vector, pointing towards the “virtual” top of the camera (or towards the top middle of the viewport, as viewed through the camera.)
This allows you to rotate the camera around the eyepoint like in these plugins:

Ruby is multi-paradigm. There are many ways to do things.

I come from broadcast engineering design (satellite news gathering vehicle systems) where it is second nature to locate things via polar coordinate systems.

If you prefer using a cartesian system, [bb.width,bb.height,0] you could have done it this way as well.

(I had to take a break for supper and Jeopardy) … continuing on

Polar coords vs. Cartesian coords.

If someone said to you "I want a view of the model extents from [-230,-215,+147.5]" … humans wouldn’t really relate directly to this.

Firstly, having moved the camera to those coordinates, and then done a view.zoom_extents, the camera would likely no longer be at those coordinates. So specifying those exact coordinates doesn’t mean much to begin with.

Secondly, 3D modeling applications and display engines vary in the coordinate / axis systems they use. (For example some of the game display engines have the Z axis pointing out toward the observer [or toward the target] and / or the Y axis pointing up.) This often requires that the model’s vertice coordinates be translated when writing out to other file formats. So, giving a coordinate without indicating what system or how the axes are oriented is insufficient information.

Now, think about needing to move the viewpoint one way of the other, because your boss wants some detail in the model (on the side viewed) to be more easily seen. (Say, that there may be some other geometry occluding the view of the “detail” the boss wants to be seen.)

How long will it take you to figure out which coordinate to change by how much amount, in order to get the view you want, using coordinates ? How will you calculate the up vector ? (Rhetorically asked, meaning the answer isn’t instantly apparent, needing time for trigonometry calculations, etc.)


The natural thing is to imagine spinning the model (or orbit the camera) toward one side or the other, until the “detail” is better viewed.

Humans relate more to named viewpoints. Ie, “SouthEast View”, “Right Front View”, etc. In most CAD 3D modeling, the front view is considered “facing North” (regardless of the eventual placement of the model object in the real world.) The “Right Front Iso View” is synonymous with “SouthEast Iso View”. This has become “traditional”. SketchUp also uses this “tradition”.

Thinking of orbiting the camera to X degrees on a compass is more natural. As is thinking of circling clockwise in 5 degree increments until we see what we want. As long as we know where the North vector points, and the target point remains the same, there will be no question that a view from the NorthEast is 45 degrees rotated from 0. A view from East is 90, and so on.

So this is why I wrote the example as I did. This, and in order to teach transformations on points and vectors, we need to use them. Also the concepts of azimuth and elevation (aka altitude) are common concepts that are worth teaching, rather than some arbitrary system that we make up ourselves.

In the image above, the satellite represents the camera eye looking at the target (which is the base point of the North and Zenith vectors.) The direction vector is the line running between the eye and the target. (Ignore the arrow pointing at the satellite, and imagine instead it points at the target.)

If I had just set up 4 constants pointing at point arrays, ie:
NE = [1,1,0], SE = [1,-1,0], SW = [-1,-1,0], NW = [-1,1,0]
… and used these to initially position the camera in the desired corner, you (or someone else) would ask what these arrays meant, what their effect was, why they did what they did.
Also, the example would be much less flexible if someone wants to specify a different viewing angle. Or use the code to position the camera for a spinning model animation, etc.


Now, there is one issue with the example. And that is it was written for producing a single view image, and then later I added a method to write all 4 corners with 1 command. But it still goes through the rigmarole of determining the visible bounds 4 times.

I figured I’d post it as is, and see if I could fix it’s efficiency later.


I hope this helps. I realize that the API information on the Camera class does not have much in the way of explanation. But it has always been terse in this sense. It is really a programmer’s reference rather than a coding textbook.
It helps to read the entire class, the descriptions of every method to gain a sense of the camera object.

This topic was automatically closed 91 days after the last reply. New replies are no longer allowed.