diff --git a/lib/sdf.rb b/lib/sdf.rb index c5d59a7..98d2899 100644 --- a/lib/sdf.rb +++ b/lib/sdf.rb @@ -16,6 +16,7 @@ require "sdf/plugin" require "sdf/sensor" require "sdf/frame" +require "sdf/loader" # The toplevel namespace for sdf # diff --git a/lib/sdf/exceptions.rb b/lib/sdf/exceptions.rb index 5efb385..a0cb2b6 100644 --- a/lib/sdf/exceptions.rb +++ b/lib/sdf/exceptions.rb @@ -1,3 +1,24 @@ module SDF class InternalError < RuntimeError; end + + module XML + # Exception raised when trying to load a model URI, but the model does + # not contain a SDF entry for the required SDF version + class UnavailableSDFVersionInModel < ArgumentError; end + # Exception raised when trying to load a file that is not a SDF file + class NotSDF < ArgumentError; end + # Exception raised when trying to load a malformed XML file + class InvalidXML < ArgumentError; end + + # Exception raised when trying to resolve a model that cannot be found + # in {model_path} + class NoSuchModel < ArgumentError + attr_reader :model_name + + def initialize(model_name) + super + @model_name = model_name + end + end + end end diff --git a/lib/sdf/loader.rb b/lib/sdf/loader.rb new file mode 100644 index 0000000..518ed68 --- /dev/null +++ b/lib/sdf/loader.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "erb" + +module SDF + # class to load SDF and ERB templated SDF files + class Loader + def initialize(erb_args: {}) + @erb_args = erb_args + end + + # Open a SDF file SDF file and returns its XML representation. + # + # @param [String] sdf_file the path to the SDF file + # @raise [Errno::ENOENT] if the files does not exist + # @raise [NotSDF] if the file is not a SDF file + # @raise [InvalidXML] if the file is not a valid XML file + # @return [REXML::Element] sdf_file's content as a REXML::Element instance + def load_sdf_raw(sdf_file) + xml_string = File.read(sdf_file) + if sdf_file.end_with?(".sdf.erb") + erb_engine = ::ERB.new(xml_string, trim_mode: "-") + xml_string = erb_engine.result_with_hash(@erb_args) + end + sdf = REXML::Document.new(xml_string) + + return sdf if sdf.root.name == "sdf" + + raise SDF::XML::NotSDF, "#{sdf_file} is not a SDF file" + rescue REXML::ParseException => e + error_message = "Cannot load #{sdf_file}: #{e.message}" + + if xml_string.match?(/<%.*?%>/m) + error_message += "\nHint: This file appears to be an ERB template. " \ + "Make sure it ends with the extension .sdf.erb" + end + + raise SDF::XML::InvalidXML, error_message + end + end +end diff --git a/lib/sdf/xml.rb b/lib/sdf/xml.rb index 66ed023..ec69017 100644 --- a/lib/sdf/xml.rb +++ b/lib/sdf/xml.rb @@ -1,30 +1,18 @@ require "rexml/document" +require "sdf/exceptions" +require "sdf/loader" module SDF module XML + class << self + attr_accessor :default_loader + end + # @!macro [new] sdf_version # @param [Integer,nil] sdf_version the maximum expected SDF version # (as version * 100, i.e. version 1.5 is represented by 150). Leave to # nil to always read the latest. - # Exception raised when trying to load a model URI, but the model does - # not contain a SDF entry for the required SDF version - class UnavailableSDFVersionInModel < ArgumentError; end - # Exception raised when trying to load a file that is not a SDF file - class NotSDF < ArgumentError; end - # Exception raised when trying to load a malformed XML file - class InvalidXML < ArgumentError; end - - # Exception raised when trying to resolve a model that cannot be found - # in {model_path} - class NoSuchModel < ArgumentError - attr_reader :model_name - - def initialize(model_name) - @model_name = model_name - end - end - # The search path for models # # It defaults to GAZEBO_MODEL_PATH @@ -47,6 +35,7 @@ def self.model_path=(path) def self.initialize @model_path = (ENV["GAZEBO_MODEL_PATH"] || "").split(":") @model_path << File.join(Dir.home, ".gazebo", "models") + @default_loader = SDF::Loader.new end initialize @@ -165,7 +154,7 @@ def self.gazebo_models(sdf_version = nil) # @raise (see model_path_of) # @raise [NoSuchModel] if the provided model name does not resolve to a # model in {model_path} - # @return [REXML::Element] + # @return [String] the path to the SDF file for the model def self.model_path_from_name(model_name, model_path: @model_path, sdf_version: nil) @gazebo_models[sdf_version] ||= {} cache = (@gazebo_models[sdf_version][model_name] ||= ModelCacheEntry.new) @@ -213,6 +202,23 @@ def self.model_from_name( end end + # Resolves relative paths and model:// URIs in the XML tree in-place + # + # This method traverses the XML tree starting from the given node, and + # expands any relative paths or `model://` URIs inside `` tags to + # absolute paths on the local filesystem. + # + # It skips `` tags because those are resolved separately during + # {.add_include_tags}. + # + # @example Replaces a model:// mesh path: + # # Before: model://robot_model/hull.dae + # # After: /path/to/workspace/robot_models/models/sdf/robot_model/hull.dae + # + # @param [REXML::Element] node the XML element to traverse + # @!macro sdf_version + # @param [String] base_path the base directory path used to resolve relative paths + # @return [void] def self.resolve_relative_uris(node, sdf_version, base_path) nodes = [node] until nodes.empty? @@ -264,6 +270,24 @@ def self.deep_copy_xml(node) # This method modifies the XML tree by replacing the include tags found # as direct children of the provided element by the included content. # + # @example + # # Before calling add_include_tags: + # # + # # + # # model://my_sensor + # # custom_sensor + # # 1 0 0 0 0 0 + # # + # # + # # + # # After calling add_include_tags: + # # + # # + # # 1 0 0 0 0 0 + # # ... + # # + # # + # # @param [REXML::Element] elem element to find include tags # @!macro sdf_version # @return [void] @@ -362,39 +386,6 @@ def self.add_include_tags(elem, sdf_version, base_path) includes end - # Open a SDF file and returns the XML representation - # - # Unlike {.load_sdf}, this really only loads the XML information, not - # resolving the include tags. - # - # @param [String] sdf_file the path to the SDF file - # @raise [Errno::ENOENT] if the files does not exist - # @raise [NotSDF] if the file is not a SDF file - # @raise [InvalidXML] if the file is not a valid XML file - # @return [REXML::Element] - def self.load_sdf_raw(sdf_file) - sdf = File.open(sdf_file) do |io| - REXML::Document.new(io) - rescue REXML::ParseException => e - unless e.message.match?(/No root/) - raise InvalidXML, "cannot load #{sdf_file}: #{e.message}" - end - - REXML::Document.new - end - - unless sdf.root - raise NotSDF, - "#{sdf_file} can be parsed as an XML file, but it does not have a root" - end - - if sdf.root.name != "sdf" && sdf.root.name != "gazebo" - raise NotSDF, "#{sdf_file} is not a SDF file" - end - - sdf - end - # Get sdf_version # # @param [REXML::Element] sdf element @@ -441,6 +432,9 @@ def self.sdf_version_of(sdf) # @param [Boolean] metadata whether the method should return a metadata hash # about the various inclusions that have been performed. See above for # the hash format + # @param [#load_sdf_raw] loader object that acts as a loader.Takes a file path as input + # and returns a REXML::Element with its content. Must respond to + # `load_sdf_raw(path: String) -> REXML::Element` # @return [REXML::Element,(REXML::Element,Hash)] either the XML tree by itself # if `metadata` is false, or the pair of the tree and the metadata hash # otherwise. @@ -449,7 +443,7 @@ def self.sdf_version_of(sdf) # @raise [InvalidXML] if the file is not a valid XML file # @return [REXML::Element] def self.load_sdf(sdf_file, flatten: true, metadata: false) - sdf = load_sdf_raw(sdf_file) + sdf = @default_loader.load_sdf_raw(sdf_file) sdf_version = sdf_version_of(sdf) sdf_metadata = Hash["includes" => {}, "path" => sdf_file] diff --git a/test/data/invalid_models/erb_model_with_sdf_extension/model.config b/test/data/invalid_models/erb_model_with_sdf_extension/model.config new file mode 100644 index 0000000..d84a0c8 --- /dev/null +++ b/test/data/invalid_models/erb_model_with_sdf_extension/model.config @@ -0,0 +1,5 @@ + + + simple_model + model.sdf + diff --git a/test/data/invalid_models/erb_model_with_sdf_extension/model.sdf b/test/data/invalid_models/erb_model_with_sdf_extension/model.sdf new file mode 100644 index 0000000..bf43744 --- /dev/null +++ b/test/data/invalid_models/erb_model_with_sdf_extension/model.sdf @@ -0,0 +1,42 @@ + +<% + default_gps_pose = [-0.679, 0.0, 1.920, 0.0, 0.0, 0.0] + default_gps2_pose = [2.571, 0.044, 0.808, 0.0, 0.0, 0.0] + + gps1_pose = (defined?(links) && links.find { |link| link[:name] == "gps" }&.dig(:pose)) || default_gps_pose + gps2_pose = (defined?(links) && links.find { |link| link[:name] == "gps2" }&.dig(:pose)) || default_gps2_pose +%> + + + + + + + + root + child + + + + + + <%= gps1_pose.join(' ') %> + + + root + gps + + + + <%= gps2_pose.join(' ') %> + + + root + gps2 + + + + + + + diff --git a/test/data/models/no_root.xml b/test/data/models/no_root.xml index 4598ae2..2b5d411 100644 --- a/test/data/models/no_root.xml +++ b/test/data/models/no_root.xml @@ -1,2 +1 @@ - diff --git a/test/data/models/simple_model_erb/model.config b/test/data/models/simple_model_erb/model.config new file mode 100644 index 0000000..8751eb6 --- /dev/null +++ b/test/data/models/simple_model_erb/model.config @@ -0,0 +1,5 @@ + + + simple_model + model.sdf.erb + diff --git a/test/data/models/simple_model_erb/model.sdf.erb b/test/data/models/simple_model_erb/model.sdf.erb new file mode 100644 index 0000000..bf43744 --- /dev/null +++ b/test/data/models/simple_model_erb/model.sdf.erb @@ -0,0 +1,42 @@ + +<% + default_gps_pose = [-0.679, 0.0, 1.920, 0.0, 0.0, 0.0] + default_gps2_pose = [2.571, 0.044, 0.808, 0.0, 0.0, 0.0] + + gps1_pose = (defined?(links) && links.find { |link| link[:name] == "gps" }&.dig(:pose)) || default_gps_pose + gps2_pose = (defined?(links) && links.find { |link| link[:name] == "gps2" }&.dig(:pose)) || default_gps2_pose +%> + + + + + + + + root + child + + + + + + <%= gps1_pose.join(' ') %> + + + root + gps + + + + <%= gps2_pose.join(' ') %> + + + root + gps2 + + + + + + + diff --git a/test/test_loader.rb b/test/test_loader.rb new file mode 100644 index 0000000..52e3ea7 --- /dev/null +++ b/test/test_loader.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "sdf/loader" +require "sdf/test" + +describe SDF::Loader do + it "fallback from .sdf to .sdf.erb and loads file without model and args" do + loader = SDF::Loader.new + + sdf_file_path = File.expand_path( + "data/models/simple_model/model.sdf", __dir__ + ) + content = loader.load_sdf_raw(sdf_file_path) + + assert_equal "simple test model", + REXML::XPath.first(content, "//model").attributes["name"] + end + + describe "#loads real file" do + before(:all) do + @models_dir = File.expand_path("data/models", __dir__) + @simple_model = File.join(@models_dir, "/simple_model_erb/model.sdf.erb") + end + + it "loads a real .sdf.erb file with args" do + erb_args = { + links: [ + { + name: "gps", + pose: [0.0, 1.0, 2.0, 3.0, 4.0, 5.0] + }, + { + name: "gps2", + pose: [6.0, 7.0, 8.0, 9.0, 0.0, 1.0] + } + ] + } + loader = SDF::Loader.new(erb_args: erb_args) + + erb_content = loader.load_sdf_raw(@simple_model) + + assert_equal "simple_model_erb", + REXML::XPath.first(erb_content, "//model").attributes["name"] + poses = REXML::XPath.match(erb_content, "//pose").map(&:text) + assert_includes poses, "0.0 1.0 2.0 3.0 4.0 5.0" + assert_includes poses, "6.0 7.0 8.0 9.0 0.0 1.0" + end + + it "InvalidXML when loading erb file with .sdf extension" do + erb_args = { + links: [ + { + name: "gps", + pose: [0.0, 1.0, 2.0, 3.0, 4.0, 5.0] + }, + { + name: "gps2", + pose: [6.0, 7.0, 8.0, 9.0, 0.0, 1.0] + } + ] + } + loader = SDF::Loader.new(erb_args: erb_args) + + invalid_models_dir = File.expand_path("data/invalid_models", __dir__) + invalid_model = File.join( + invalid_models_dir, "/erb_model_with_sdf_extension/model.sdf" + ) + error = assert_raises(SDF::XML::InvalidXML) do + loader.load_sdf_raw(invalid_model) + end + assert_match(/Hint: This file appears to be an ERB template/, error.message) + end + + it "loads a real .sdf.erb file without args" do + erb_content = SDF::Loader.new.load_sdf_raw(@simple_model) + + assert_equal "simple_model_erb", + REXML::XPath.first(erb_content, "//model").attributes["name"] + poses = REXML::XPath.match(erb_content, "//pose").map(&:text) + assert_includes poses, "-0.679 0.0 1.92 0.0 0.0 0.0" + assert_includes poses, "2.571 0.044 0.808 0.0 0.0 0.0" + end + + it "validates that the file is a XML file" do + assert_raises(SDF::XML::InvalidXML) do + SDF::Loader.new.load_sdf_raw(File.join(@models_dir, "not_xml.xml")) + end + end + it "validates that the file has a root" do + assert_raises(SDF::XML::InvalidXML) do + SDF::Loader.new.load_sdf_raw(File.join(@models_dir, "no_root.xml")) + end + end + it "validates that the file is a SDF file" do + assert_raises(SDF::XML::NotSDF) do + SDF::Loader.new.load_sdf_raw(File.join(@models_dir, "not_sdf.xml")) + end + end + it "validates that the file exists" do + assert_raises(Errno::ENOENT) do + SDF::Loader.new.load_sdf_raw(File.join(@models_dir, + "does_not_exist.xml")) + end + end + end +end diff --git a/test/test_xml.rb b/test/test_xml.rb index ee5724f..e6d5e28 100644 --- a/test/test_xml.rb +++ b/test/test_xml.rb @@ -60,7 +60,7 @@ def invalid_models_dir describe "gazebo_models" do it "loads all models available in the path" do models = SDF::XML.gazebo_models - assert_equal 23, models.size + assert_equal 24, models.size assert(sdf = models["simple_model"]) model = sdf.elements.enum_for(:each, "sdf/model").first @@ -96,7 +96,7 @@ def invalid_models_dir end end it "validates that the file has a root" do - assert_raises(SDF::XML::NotSDF) do + assert_raises(SDF::XML::InvalidXML) do SDF::XML.load_sdf(File.join(models_dir, "no_root.xml")) end end @@ -169,10 +169,12 @@ def invalid_models_dir sdf = SDF::XML.load_sdf(File.join(models_dir, "model_with_relative_file_in_uri", "model.sdf")) uri = sdf.elements.to_a("//uri").first - assert_equal( - File.join(models_dir, "model_with_relative_file_in_uri", - "visual.dae"), uri.text + expected_full_path = File.expand_path( + File.join( + models_dir, "model_with_relative_file_in_uri", "visual.dae" + ) ) + assert_equal(expected_full_path, uri.text) end it "resolves relative paths to other model's paths in tags" do sdf = SDF::XML.load_sdf(File.join(models_dir, @@ -184,10 +186,10 @@ def invalid_models_dir sdf = SDF::XML.load_sdf(File.join(models_dir, "model_that_includes_a_model_with_relative_paths", "model.sdf")) uri = sdf.elements.to_a("//uri").first - assert_equal( - File.join(models_dir, "model_with_relative_uris", - "visual.dae"), uri.text + expected_full_path = File.expand_path( + File.join(models_dir, "model_with_relative_uris", "visual.dae") ) + assert_equal(expected_full_path, uri.text) end it "resolves model:// in tags" do sdf = SDF::XML.load_sdf(File.join(models_dir, @@ -204,9 +206,9 @@ def invalid_models_dir metadata: true ) - model_full_path = File.expand_path(File.join( - "data", "models", "simple_model", "model.sdf" - ), __dir__) + model_full_path = File.join( + models_dir, "simple_model", "model.sdf" + ) expected = [ "w::child_of_world", "w::model::child_of_model", @@ -214,7 +216,6 @@ def invalid_models_dir "root_model::child_of_root_model", "root_model::model_in_root_model::child_of_model_in_root_model" ] - assert_equal [model_full_path], metadata["includes"].keys assert_equal expected.sort, metadata["includes"][model_full_path].sort @@ -227,12 +228,10 @@ def invalid_models_dir metadata: true ) - ur10_full_path = File.expand_path(File.join( - "data", "regressions", "ur10", "ur10.sdf" - ), __dir__) - dual_ur10_full_path = File.expand_path(File.join( - "data", "regressions", "dual_ur10", "model.sdf" - ), __dir__) + ur10_full_path = File.join(regressions_dir, "ur10", "ur10.sdf") + dual_ur10_full_path = File.join( + regressions_dir, "dual_ur10", "model.sdf" + ) expected = Hash[ ur10_full_path => %w[ empty_world::dual_ur10_fixed::dual_ur10::left_arm