Waiting for Sketchup.send_action to complete before continuing

Hi, I am working on a feature for my company’s plugin that will automatically set the view of the model and export a PNG image of it.
My code for this uses Sketchup.send_action("view###:"), where ### is either “Top” or “Iso” depending on some internal criteria. After that is called, I loop through the layers of the current model and hide all layers except those defined by the company. I then set up local variables to hold the active view and active entities, I call view.zoom entities to zoom in on what is still visible of the model, and I call Sketchup.active_model.active_view.write_image(keys) where keys = { :filename => filepath, :antialias => true, :transparent => true }.

Everything does what I want it to independently, but the problem I am running into when I try to put it all together is that the Sketchup.send_action() command runs asynchronously, which means it usually ends up executed last. I have read here and in other SketchUp forums that threads are not a viable option because of the way SketchUp implements Ruby and I have tried using sleep(x) with various time increments and UI.start_timer with varying time increments as well, but nothing has worked so far. I understand that sleep(x) is likely not working because sleep blocks the thread for x amount of time, thus nothing else in my code is executed during that time, and I was having problems using UI.start_timer because that also appears to run asynchronously. With all that being said, is there some way to force SketchUp to wait until send_action is done before continuing its execution?

Thanks in advance for your help on this, and I will be happy to provide any clarification that is needed.

UPDATE: There are several possible solutions in the thread below. The solution I chose is the solution I used. Thanks to the immense amount of background detail and clarification provided by the solution’s author (@DanRathbun), it was easy to understand and implement and it worked very well for me, better than the solution I was originally seeking. Thanks for all the responses to this thread and the helpful comments and suggestions.

your doing something odd…

you can always force a refresh after a send_action

view = Sketchup.active_model.active_view
Sketchup.send_action('viewTop:')  ## Top  ### ⌘1
view.refresh
sleep 1
Sketchup.send_action('viewBottom:')  ## Bottom  ### ⌘2
view.refresh
sleep 1
Sketchup.send_action('viewFront:')  ## Front  ### ⌘3
view.refresh
sleep 1
Sketchup.send_action('viewBack:')  ## Back  ### ⌘4
view.refresh
sleep 1

supply a model and example code for better evaluation…

john

EDIT: In the course of this thread, we found that a better alternative was to just manipulate the view’s camera object, rather than using a send_action('viewIso:') and ViewObserver.

For that example, see this separate thread:

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

… back to the old discussion on send_action('viewIso:') and ViewObserverend EDIT.


You need to use a ViewObserver to tell when SketchUp has finished changing the view …

Here is a working example …

EDIT: Although I just realized it could fail. If the call to view.zoom(entities) does not actually cause a change in the view (ie, it’s already zoomed,) then the 2nd loop in the onViewChanged() callback might never fire.

I’d need to think on how to detect this. (Possibly a before <=> after camera comparison.)

save_view_as_png.rb (3.0 KB)

… which looks like this …

module Author; end
module Author::WriteViewImages

  extend self

  @@loaded ||= false
  
  EXT     ||= 'png'
  COMPANY ||= 'CompanyName'

  class ViewSpy < Sketchup::ViewObserver
    @@debug = false
    def initialize(parent)
      @loop = 0
      @parent = parent # Parent namespace
    end
    def onViewChanged(view)
      @loop += 1
      if @loop == 1
        # Called via send_action("view###:")
        puts "In onViewChanged() : loop 1 " if @@debug
        @parent.hide_layers()
        view.zoom(view.model.entities)
      elsif @loop == 2
        # Called via zoom in 1st loop
        puts "In onViewChanged() : loop 2 " if @@debug
        @parent.write_view_file()
        @loop = 0
        @parent.restore_view()
      end
    end
  end

  def get_filepath(viewname)
    path = File.dirname(Sketchup.active_model.path)
    if path.empty?
      filename = "UNTITLED_#{viewname}.#{EXT}"
    else
      name = Sketchup.active_model.title
      filename = "#{name}_#{viewname}.#{EXT}"
      filepath = path
    end
    @filepath = UI.savepanel(
      'Choose file save location ...',
      path,
      filename
    )
  end

  def hide_layers()
    @layers = {}
    Sketchup.active_model.layers.each {|l|
      @layers[l.name]= l.visible?
      # Switch off non-company layers ...
      next if l.name == 'Layer0' # Layer0 must always be visible!
      l.visible= false if l.name !~ /#{COMPANY}/
    }
  end

  def restore_layers()
    layers = Sketchup.active_model.layers
    @layers.each {|key,state|
      layers[key].visible= state
    }
  end

  def remember_view()
    view = Sketchup.active_model.active_view
    cam = view.camera
    @camera = cam.eye,cam.target,cam.up
    view.add_observer(@spy)
  end

  def restore_view()
    view = Sketchup.active_model.active_view
    view.remove_observer(@spy)
    restore_layers()
    view.camera.set(*@camera)
  end

  def save_iso_view()
    get_filepath('iso')
    remember_view()
    Sketchup.send_action('viewIso:')
  end

  def save_top_view()
    get_filepath('top')
    remember_view()
    Sketchup.send_action('viewTop:')
  end

  def write_view_file()
    Dir::chdir(File.dirname(@filepath)) {
      file = File.basename(@filepath)
      if File.exist?(file)
        File.delete(file)
        sleep 2
      end
      Sketchup.active_model.active_view.write_image({
        :filename    => @filepath,
        :antialias   => true,
        :transparent => true
      })
      sleep 1
      until File.exist?(file)
        sleep 1
        files = Dir.glob("*.#{EXT}")
        break if files.include?(file)
      end
    } # back in prev directory
  end

  unless @@loaded
    @spy = ViewSpy::new(self) # pass in this outer namespace
    UI.add_context_menu_handler {|popup|
      submenu = popup.add_submenu('Save View Image')
      submenu.add_item('Save Iso View') { save_iso_view() }
      submenu.add_item('Save Top View') { save_top_view() }
    }
    @@loaded = true
  end

end

I think using of “Sketchup.send_action(“view###:”)” is in some sense lazy approach to set appropriate view. I would suggest to use Camera class methods to set appropriate view and View class methods to zoom. It may require slightly more coding, but should run synchronously and sequence of commands may be united into one undoable single action.

2 Likes

I concur!

Moving the camera yourself is also more future proof as the undocumented asynchronous behavior of send_action may change. Also it would require less code than using observers, and it would be much easier to understand how the code works if you one day several years into the future need to modify the plugin.

Making everything synchronous would be an ideal solution, but I am not familiar enough with the Camera class or with moving the camera in 3D space to know how to mimic the view points that I need and send_action("view###") does exactly what I am trying to do. Could you or @eneroth3 provide an example of how to move the camera to a view point that mimics the “Iso” button on the view toolbar? That would be very helpful to me in understanding this sort of an approach.

I have attached a sample model for you and the code is basically doing the following:

def sample
	model = Sketchup.active_model
	layers = model.layers
	model.active_layer = layers["Layer0"] # selects a layer that will never be hidden

	view = model.active_view
	if some_criteria # criteria set by the business to determine which view to use and
                     # which layers to show/hide
		Sketchup.send_action("viewIso:")
		layers.each{ |layer|
			# show/hide layers based on company criteria
		}
	else
		Sketchup.send_action("viewTop:")
		layers.each{ |layer|
			# show/hide layers based on company criteria
		}
	end

	image_area = model.active_entities
	view.zoom image_area

	keys = {
		:filename => file_path,
		:antialias => true,
		:transparent => true
	}
	view.write_image(keys)
end

The code is not all in one function in our plugin, but it is in functions that are called successively once a button in the UI is pushed.

Also, as a side note, I tried refreshing the view and using sleep(1) after the refresh as you showed in your reply, but it didn’t change anything for me, the send_action still completed after everything else, so it was not captured by the image. Thanks for your quick reply! Let me know if my request makes any more sense with what I’ve given you here.
sample.skp (358.7 KB)

if I add a path and the conditional it works as is…

are you sure the error doesn’t lay elsewhere…

john

That is possible. I’ll try rearranging the code a little bit and isolating this functionality to see if I can get it to work.

As I showed in the above example, it does not. Ie, it is not that easy. Whether SketchUp changes it’s view or redraws the UI or GUI elements, it does this asynchronously. I showed this in the loop 1 of the ViewObserver callback where we had to react the view.zoom changing the view. The same thing will happen with camera.set.

Actually, it has variable behavior that you might not be aware of. Re, switching to an ISO viewpoint, it depends upon which of the 4 corners you are closer to. This call (to "viewIso:") moves to the nearest corner and then changes to a ISo viewpoint. So your image could be viewing the model from a direction you do not expect, all depending upon what the user was doing and how they were viewing the model just before the call.

So Julia’s comment …

… is mostly valid. Also the "send_action"s are basically firing user GUI actions where waiting does not matter. (menu commands, etc.) They were never really expected to be used in synchronous linear code. They were just meant as a quick way to have toolbar buttons do what the user wanted when clicked. (Also, the team members have talked about replacing the entire set of “send actions” with proper method calls upon the correct object, ie, app, view, etc.)

But …

… as I noted above, I don’t think you can get entirely away from using an observer. You’ll never know how long it will take to rerender the view as the model could be small and simple, or large and complex. So it will not always work to just use the global sleep method.

Firstly, I showed working with the camera in the above example.

In method remember_view() we use Ruby’s implicit array assignment operation to create an array (referenced as @camera) holding the 3 objects you’d need to later restore the camera to a known position (whatever it is, and in this case we do not care exactly where it is, just that later we can restore it to whereever it was.)

@camera = cam.eye, cam.target, cam.up

Then in method restore_view(), I showed using this array (which by the way contains a point3d, point3d, and a vector3d,) and Ruby’s “splat” (*) operator, to serve up the 3 parameters that the camera.set() method expects.
(Again see the implicit array assignment section, of the Ruby doc Assignment article. Also, the splat operator works both ways. It can gather up multiple parameters into an array in a method parameter list, or explode an array into a parameter list.)

view.camera.set( *@camera )

This above is the shorthand for doing this to remember:

@eye    = cam.eye
@target = cam.target
@up     = cam.up

… and this to restore …

view.camera.set( @eye, @target, @up )

Now, for an example. Let us start with the TOP view because it’s easier to visualize.

(1) You want to target the center of what is not hidden.
For the entire model this is easy.

target = model.bounds.center

But, you will be hiding objects, so you want to deal with a bounding box that contains only what you’ve left visible:

bb = Geom::BoundingBox::new
ents =  model.entities.each {|e|
  next unless e.is_a?(Sketchup::Drawingelement)
  # if so will respond to :visible? and :bounds
  next if e.is_a?(Sketchup::ConstructionLine)
  # avoid infinite clines !
  bb.add(e.bounds) if e.visible?
}
target = bb.center

(2) You want to position the camera’s eye directly above the target, so then it’s x and y coords will be identical to the target.
You will want the z coord to be above and likely outside the bounds, so use the entire height of the bounding box.

eye = Geom::Point3d::new( target.x, target.y, bb.height )

Note that in SketchUp Ruby arrays are compatible with both points and vectors, so you could also just transform the target point, by an array representing a transitional vector whose z component is bb.height

eye = target.transform([ 0, 0, bb.height ])

The result is the same, … a new point directly above the target, by a distance of bb.height.

EDIT: Actually the result is not the same. I just noticed an error. The first example puts the point on the top surface of the bounding box. The 2nd example is what I wanted which is half the bb.height above the bounding box. (We could always just multiply the bb.height by some factor within the parameter list to be sure the point is high enough.)

(3) The up vector argument is the easiest here, you always want the x axis at the bottom incrementing to the right, and the y axis on the left incrementing towards the top of the screen.
You can use a vector3d …

up = Geom::Vector3d::new( 0, 1, 0 )

… or a simple array (which camera.set will also accept) …

up = [ 0, 1, 0 ]

(4) You then use the 3 computed arguments …

view.camera.set( eye, target, up )

He is on Windows John, your on Mac …

Dan, do you know if SU ruby works differently for this example?

I guess the path may, but I didn’t offer what I had used…

#I  changed the OP's method, adding arguments
def sample(some_criteria, file_path)

# and then added this  in Ruby Console
path = File.expand_path(File.join('~', 'Desktop', '_sample.png'))
# and called the method
sample(true, path)

john

EDITED: to fix minor grammatical errors…

When I isolated the code I posted here and ran only that code, it still did not set the view before saving the image, so I would assume that there must be some differences in the SketchUp versions for each OS that are preventing it from working as intended on Windows.

Once again, I do not know what it is you are getting at John, because of your tendency to speak in short phrases, that assumes the reader is sharing the same thoughts.

The two “phrases” are missing words and have words misspelled.

What I think is that historically, Mac events have sometimes been asynchronous whilst they are synchronous on Windows, and visa versa. So I am pointing out the obvious difference in the behavior you both are seeing.


I do not know what this comment means.

# and then added in RC

This will work on Windows IF the %HOME% environment variable is set. SketchUp began setting it around v2013 or v2014 (cannot remember when exactly. Prior to that, I used to set the %HOME% var myself.)

path = File.expand_path(File.join('~', 'Desktop', '_sample.png'))

This example is fantastic, using the camera makes a lot more sense now.

In your view observer example from your first response:

if I change the camera myself instead of using send_action("view###:"), am I correct in understanding that this view observer would now be triggered by me changing the camera? If not, how would I be able to use a view observer while changing the camera myself instead of using send_action?

Also, as a side note, if I use a view observer, would it be triggered by the user rotating the model, etc., while they are building it? If so, how could I avoid/get around that in order to only trigger the view observer when I am ready for it to be triggered?

ADD: Sorry, John (@john_drivenupthewall) if I’m a bit “snippy”. There appears to be two conversations going on in this thread, and I’m concentrating on the other, and occupied with long replies, and missing what you 2 are speaking of, which comes from the 2nd post I think, which I thought I had debunked in post 3.

Yes I do believe so !

YES ! But it will not be called until they let up on the middle mouse button, and the view is done changing.

There is no way to stop the user from changing the view, nor the internal SketchUp engine from calling callbacks in other plugin’s ViewObservers.

ADD: This is called “event driven code”. Your code is reacting to an event triggered by means outside of your control. So your code must always be ready to handle what happens.

But you CAN control what happens in YOUR ViewObservers. Either by using a switch variable (like I did with @loop,) or by switching it off and on, similar to what I did by attaching and detaching the singleton observer instance in the above example. You can also just use a switch variable and put a return statement at the beginning of any callback methods in your observer, …

def some_callback()
  return if @off
  # rest of callback code
end

I need to stress a trick I used in the example above. When you do not want the observer to fire, you can detach it, change the view, then reattach the observer.

In this case you’d NOT want to leave the observer constantly attached whilst the user was editing the model.

Perfect, I was just about to ask that. Thanks for your help! I will do some work with the examples you have given me and post an update later when either I have more questions or I’ve got everything working.

Also, one other question. I noticed that you wrapped your view observer class inside a module. Is there a benefit to using a module as the outer structure instead of a class? The way our plugin is currently set up, I would be using the view observer inside a class as opposed to a module.

As an aside, I answered only the question you posed in the original thread opener.
A bit off topic, (and we could start a new thread on it,) but I would not do it this way myself.

What I'd do using a scene page instead ... (click to view)

I’d set up a template with a scene page that had all the proper styling and layers off that needed to be invisible, and those on that need to be seen, with the camera in the proper position, and then I’d have a command that switched over to that scene page and use a FrameChangeObserver to detect when the scene change was complete, then write the view to file. In this scenario again the observer would only be attached temporarily until the file was written. At the end of the command the page is switched back to the “work” page.