Scene modification observer

I need an observer that fires when a scene is modified (aka updated). I’ve found the PageObserver has a rather frustrating onContentsModified callback that has the Pages collection, not the individual modified page, as argument and have built a whole wrapper around it to compare scene states, only to find the PageObserver doesn’t fire when the documentation suggested.

It fires when:
scenes are added,
scenes are removed,
scenes are re-ordered,
active scene changes and
scenes are renamed (this is a change to an individual scene, not the collection).

It does not fire when:
user rights clicks scene tab and select update,
user clicks on update button in Scenes inspector,
user ticks or un-ticks scene property check boxes,
Ruby API Page#update is called,
Ruby API Page#camera is altered.

Is there any observer that actually does fire when scenes are modified? What arguments are passed isn’t relevant to me as I’ve already solved the state tracking issue, I just need an event to fire.

If anyone is interested, here’s my current code. It works with the exception that only a handful of scene properties are compared at the time being, and that it doesn’t fire when it should, making it essentially useless.

# scene_change_observer.rb

# Observer for listening to scenes being modified, added or deleted.
#
# @abstract Create your own class with these method to listen to scene
#   modification, addition or deletion.
#
# @example
#  class MySceneChangeObserver
#    def onContentsModified(_scenes, modified_scenes)
#      puts "onContentsModified: #{modified_scenes.map(&:name)}"
#    end
#
#    def onElementAdded(_scenes, scene)
#      puts "onElementAdded: #{scene.name}."
#    end
#
#    def onElementRemoved(_scenes, scene)
#      puts "onElementRemoved: #{scene.name}."
#    end
#  end
#  SceneChangeNotifier.add_observer(MySceneChangeObserver.new)
class SceneChangeObserver
  # Invoked when scenes "changes". It is unclear in the SketchUp API what
  # exactly this means, but it seems to include the scene are modified as
  # well as when the active scene changes.
  #
  # @param scenes [Sketchup::Pages]
  # @param modified_scenes [Array<Sketchup::Page>]
  #
  # @return Void.
  def onContentsModified(scenes, modified_scenes); end

  # Invoked when a scene is added.
  #
  # @param scenes [Sketchup::Pages]
  # @param scene [Sketchup::Page]
  #
  # @return Void.
  def onElementAdded(scenes, scene); end

  # Invoked when a scene is removed.
  #
  # @param scenes [Sketchup::Pages]
  # @param scene [Sketchup::Page]
  #
  # @return Void.
  def onElementRemoved(scenes, scene); end
end
# scene_change_notifier.rb

require "serialized_scene"

# Notify when scenes are being modified, added or deleted.
#
# This is a wrapper to Sketchup::PagesObserver, with the addition of the
# `changed_pages` parameter for the onContentsModified callback, holding
# references to the scenes that were actually modified.
#
# For observer implementation, see `SceneChangeObserver`.
#
# Observers are kept throughout the SketchUp session, unlike some of the
# SketchUp API observer interfaces that need to be re-attached for each new
# model.
module SceneChangeNotifier
  # FIXME: Observers are not notified on scene change (change meaning
  # modification) as the SketchUp PagesObserver#onContentsModified
  # doesn't fire when scenes are modified.
  # See https://forums.sketchup.com/t/scene-modification-observer/94981

  # As the SketchUp PageObserver interface doesn't tell what scene was
  # changed, just that some scene was changed, a cache of the previous
  # scene states is needed to compare against.
  #
  # Note that this cache may contain scenes from different models.
  @scene_cache = {}

  @observers ||= Set.new

  # Make observer be invoked when scenes are activated.
  #
  # @param observer [Object] See `SceneChangeObserver` for details.
  #
  # @return [Void]
  def self.add_observer(observer)
    @observers.add(observer)

    nil
  end

  # Stop observer from being invoked when scenes are activated.
  #
  # @param observer [Object] See `SceneChangeObserver` for details.
  #
  # @return [Void]
  def self.remove_observer(observer)
    @observers.delete(observer)

    nil
  end

  # Private

  # Update scene cache to reflect current state of the scenes in active
  # model.
  #
  # @return [Void]
  def self.update_scene_cache
    Sketchup.active_model.pages.each do |scene|
      @scene_cache[scene] = SerializedScene.new(scene)
    end
    # TODO: Purge deleted scenes to clean up memory.
  end
  private_class_method :update_scene_cache

  # Get scenes in active model that have been modified since cache was last
  # updated.
  #
  # @return [Array<Sketchup::Page>]
  def self.modified_scenes
    Sketchup.active_model.pages.select do |scene|
      @scene_cache[scene] != scene
    end
  end
  private_class_method :modified_scenes

  # @private
  def self.on_contents_modified(pages)
    modified_pages = modified_scenes
    update_scene_cache

    @observers.each do |observer|
      if observer.respond_to?(:onContentsModified)
        observer.onContentsModified(pages, modified_pages)
      end
    end
  end

  # @private
  def self.on_element_added(pages, page)
    @observers.each do |observer|
      if observer.respond_to?(:onElementAdded)
        observer.onElementAdded(pages, page)
      end
    end
  end

  # @private
  def self.on_element_removed(pages, page)
    @observers.each do |observer|
      if observer.respond_to?(:onElementRemoved)
        observer.onElementRemoved(pages, page)
      end
    end
  end

  # @private
  def self.on_init_model
    update_scene_cache
  end

  # @private
  class PagesObserver < Sketchup::PagesObserver
    def onContentsModified(*args)
      SceneChangeNotifier.on_contents_modified(*args)
    end

    def onElementAdded(*args)
      SceneChangeNotifier.on_element_added(*args)
    end

    def onElementRemoved(*args)
      SceneChangeNotifier.on_element_removed(*args)
    end
  end

  # @private
  class AppObserver < Sketchup::AppObserver
    PAGE_OBSERVER ||= PagesObserver.new

    def expectsStartupModelNotifications
      true
    end

    def onNewModel(model)
      model.pages.add_observer(PAGE_OBSERVER)
      SceneChangeNotifier.on_init_model
    end

    def onOpenModel(model)
      model.pages.add_observer(PAGE_OBSERVER)
      SceneChangeNotifier.on_init_model
    end
  end

  unless @loaded
    @loaded = true
    Sketchup.add_observer(AppObserver.new)
  end
end
# serialized_scene.rb

# Serialize `Sketchup::Page` into custom object to be able to compare
# at a later point if scene has been modified.
class SerializedScene
  # @param scene [Sketchup::Page]
  def initialize(scene)
    @name = scene.name
    @eye = scene.camera.eye
    @target = scene.camera.target
    @up = scene.camera.up
    # TODO: Add more properties to compare.
  end

  # Compare serialized scene to Page object.
  #
  # @param other [Skecthup::Page]
  #
  # @return [Boolean]
  def ==(other)
    @name == other.name \
    && @eye == other.camera.eye \
    && @target == other.camera.target \
    && @up == other.camera.up
  end
end
1 Like

So have others. ie …

Please add comments and / or test examples to tracker issue.

1 Like

I could intersect Page#update by aliasing it and override it with my own method that sends a callback to my code and then calls the original API method, but it’s a ugly hack, and would not work for scene changes made through the GUI :confused: . Maybe you can listen to GUI interaction outside of the Ruby API, but such methods would be platform dependent :confused: .