From 3953ecf583dea44e3f1c61aaf175477baeb0d463 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Tue, 4 Aug 2026 17:51:19 -0300 Subject: [PATCH 1/6] add register_in_memory_model to cache models --- lib/sdf/xml.rb | 64 ++++++++++++++++++++++++++++++++++++++++++++++-- test/test_xml.rb | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/lib/sdf/xml.rb b/lib/sdf/xml.rb index 66ed023..f863927 100644 --- a/lib/sdf/xml.rb +++ b/lib/sdf/xml.rb @@ -39,8 +39,11 @@ def self.model_path # @param [Array] path list of directories in which we should # search for models def self.model_path=(path) - @model_path = Array(path) - clear_cache + new_path = Array(path) + if @model_path != new_path + @model_path = new_path + clear_cache + end end # load model_path with default parameters @@ -158,6 +161,63 @@ def self.gazebo_models(sdf_version = nil) ModelCacheEntry = Struct.new :path, :xml, :metadata + # Registers an in-memory XML model in the cache to avoid disk lookup + # + # @param [String] model_name the target name in the cache + # @param [REXML::Document,REXML::Element,String] xml_doc the XML model representation + def self.register_in_memory_model(model_name, xml_doc, sdf_version: nil) + xml = case xml_doc + when REXML::Document + xml_doc + when REXML::Element + doc = REXML::Document.new + doc.add(xml_doc) + doc + when String + REXML::Document.new(xml_doc) + else + raise ArgumentError, "Expected REXML::Document, REXML::Element, or String, got #{xml_doc.class}" + end + + if sdf_version.nil? && xml.root && xml.root.name == "sdf" + version_str = xml.root.attributes["version"] + if version_str + sdf_version = (Float(version_str) * 100).to_i rescue nil + end + end + + @gazebo_models[sdf_version] ||= {} + cache = (@gazebo_models[sdf_version][model_name] ||= ModelCacheEntry.new) + cache.path = "virtual://#{model_name}" + cache.xml = xml + cache.metadata = { "includes" => {}, "path" => cache.path } + + # Also register under nil as a generic fallback + if sdf_version + @gazebo_models[nil] ||= {} + cache_nil = (@gazebo_models[nil][model_name] ||= ModelCacheEntry.new) + cache_nil.path = "virtual://#{model_name}" + cache_nil.xml = xml + cache_nil.metadata = { "includes" => {}, "path" => cache_nil.path } + end + end + + # Checks if a model name is already cached in memory + # + # @param [String] model_name the target name + # @return [Boolean] + def self.cached_model?(model_name, sdf_version: nil) + # Check version-specific cache + if entry = @gazebo_models.dig(sdf_version, model_name) + return true if entry.xml + end + # Check fallback cache + if sdf_version && (entry = @gazebo_models.dig(nil, model_name)) + return true if entry.xml + end + false + end + # Finds the path to the SDF for a gazebo model and SDF version # # @param [String] model_name the model name diff --git a/test/test_xml.rb b/test/test_xml.rb index ee5724f..5f8e94b 100644 --- a/test/test_xml.rb +++ b/test/test_xml.rb @@ -426,6 +426,52 @@ def sdf_model_in_model_that_replaces_pose_in_include model = sdf2.elements.enum_for(:each, "sdf/model").first assert_equal("versioned model 1.3", model.attributes["name"]) end + + describe "in-memory registration and caching" do + before do + # Clear the gazebo models cache before each test + SDF::XML.instance_variable_get(:@gazebo_models).clear + end + + it "allows registering a model as a REXML::Document" do + refute SDF::XML.cached_model?("virtual_model") + + doc = REXML::Document.new("") + SDF::XML.register_in_memory_model("virtual_model", doc) + + assert SDF::XML.cached_model?("virtual_model") + assert_equal doc, SDF::XML.model_from_name("virtual_model", flatten: false) + end + + it "allows registering a model as a REXML::Element" do + refute SDF::XML.cached_model?("virtual_el") + + element = REXML::Element.new("model") + element.add_attribute("name", "virtual") + SDF::XML.register_in_memory_model("virtual_el", element) + + assert SDF::XML.cached_model?("virtual_el") + loaded = SDF::XML.model_from_name("virtual_el", flatten: false) + assert_equal element, loaded.root + end + + it "allows registering a model as a raw XML String" do + refute SDF::XML.cached_model?("virtual_str") + + xml_string = "" + SDF::XML.register_in_memory_model("virtual_str", xml_string) + + assert SDF::XML.cached_model?("virtual_str") + loaded = SDF::XML.model_from_name("virtual_str", flatten: false) + assert_equal "virtual", loaded.root.attributes["name"] + end + + it "raises ArgumentError when registering an invalid type" do + assert_raises(ArgumentError) do + SDF::XML.register_in_memory_model("invalid_model", 12_345) + end + end + end it "raises if the model cannot be found" do exception = assert_raises(SDF::XML::NoSuchModel) do SDF::XML.model_from_name("does_not_exist") From 1982b0ca12d38dfa4464992998f4004c856f9702 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Tue, 4 Aug 2026 20:28:35 -0300 Subject: [PATCH 2/6] support recursive models and registering with sdf_version = nil --- lib/sdf/xml.rb | 57 ++++++++++++++++++++++++++++++++++++------------ test/test_xml.rb | 36 ++++++++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 16 deletions(-) diff --git a/lib/sdf/xml.rb b/lib/sdf/xml.rb index f863927..2b6ab7b 100644 --- a/lib/sdf/xml.rb +++ b/lib/sdf/xml.rb @@ -161,11 +161,12 @@ def self.gazebo_models(sdf_version = nil) ModelCacheEntry = Struct.new :path, :xml, :metadata - # Registers an in-memory XML model in the cache to avoid disk lookup + # Registers an already loaded in-memory XML model and its metadata in the cache # # @param [String] model_name the target name in the cache # @param [REXML::Document,REXML::Element,String] xml_doc the XML model representation - def self.register_in_memory_model(model_name, xml_doc, sdf_version: nil) + # @param [Hash,nil] metadata the pre-resolved include metadata + def self.register_in_memory_model(model_name, xml_doc, sdf_version: nil, metadata: nil) xml = case xml_doc when REXML::Document xml_doc @@ -186,19 +187,24 @@ def self.register_in_memory_model(model_name, xml_doc, sdf_version: nil) end end + virtual_path = "virtual://#{model_name}" + metadata ||= {} + metadata["includes"] ||= {} + metadata["path"] ||= virtual_path + @gazebo_models[sdf_version] ||= {} cache = (@gazebo_models[sdf_version][model_name] ||= ModelCacheEntry.new) - cache.path = "virtual://#{model_name}" - cache.xml = xml - cache.metadata = { "includes" => {}, "path" => cache.path } + cache.path = metadata["path"] + cache.xml = xml + cache.metadata = metadata # Also register under nil as a generic fallback if sdf_version @gazebo_models[nil] ||= {} cache_nil = (@gazebo_models[nil][model_name] ||= ModelCacheEntry.new) - cache_nil.path = "virtual://#{model_name}" - cache_nil.xml = xml - cache_nil.metadata = { "includes" => {}, "path" => cache_nil.path } + cache_nil.path = cache.path + cache_nil.xml = xml + cache_nil.metadata = metadata end end @@ -231,6 +237,14 @@ def self.model_path_from_name(model_name, model_path: @model_path, sdf_version: cache = (@gazebo_models[sdf_version][model_name] ||= ModelCacheEntry.new) return cache.path if cache.path + # Fallback to the nil cache for virtual in-memory models + if sdf_version && (nil_cache = @gazebo_models.dig(nil, model_name)) && nil_cache.path && nil_cache.path.start_with?("virtual://") + cache.path = nil_cache.path + cache.xml = nil_cache.xml if nil_cache.xml + cache.metadata = nil_cache.metadata if nil_cache.metadata + return cache.path + end + model_path.each do |p| model_dir = File.join(p, model_name) if File.file?(File.join(model_dir, "model.config")) @@ -508,18 +522,24 @@ def self.sdf_version_of(sdf) # @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(sdf_file, flatten: true, metadata: false) - sdf = load_sdf_raw(sdf_file) + # Processes an in-memory SDF XML tree, resolving its include tags and relative URIs + # + # @param [REXML::Document] sdf the XML tree + # @param [Boolean] flatten flattens the XML model or not + # @param [Boolean] metadata returns a metadata hash or not + # @param [String,nil] path the file path or virtual path representing the SDF + # @return [REXML::Element, [REXML::Element, Hash]] + def self.resolve_sdf_xml(sdf, flatten: true, metadata: false, path: nil) sdf_version = sdf_version_of(sdf) + base_path = path ? File.dirname(path) : nil - sdf_metadata = Hash["includes" => {}, "path" => sdf_file] - includes = add_include_tags(sdf.root, sdf_version, File.dirname(sdf_file)) + sdf_metadata = Hash["includes" => {}, "path" => path] + includes = add_include_tags(sdf.root, sdf_version, base_path) sdf_metadata["includes"].merge!(includes) do |_, old, new| old + new end - resolve_relative_uris(sdf.root, sdf_version, File.dirname(sdf_file)) + resolve_relative_uris(sdf.root, sdf_version, base_path) - sdf = deep_copy_xml(sdf) flatten_model_tree(sdf.root) if flatten if metadata @@ -527,6 +547,15 @@ def self.load_sdf(sdf_file, flatten: true, metadata: false) else sdf end + end + + # Loads a SDF file and returns the XML representation + # + # Unlike {.load_sdf_raw}, this resolves the include tags in the XML representation + def self.load_sdf(sdf_file, flatten: true, metadata: false) + sdf = load_sdf_raw(sdf_file) + sdf = deep_copy_xml(sdf) + resolve_sdf_xml(sdf, flatten: flatten, metadata: metadata, path: sdf_file) rescue Exception => e raise e, "while loading #{sdf_file}: #{e.message}", e.backtrace end diff --git a/test/test_xml.rb b/test/test_xml.rb index 5f8e94b..2d26b7f 100644 --- a/test/test_xml.rb +++ b/test/test_xml.rb @@ -440,7 +440,7 @@ def sdf_model_in_model_that_replaces_pose_in_include SDF::XML.register_in_memory_model("virtual_model", doc) assert SDF::XML.cached_model?("virtual_model") - assert_equal doc, SDF::XML.model_from_name("virtual_model", flatten: false) + assert_equal doc.to_s, SDF::XML.model_from_name("virtual_model", flatten: false).to_s end it "allows registering a model as a REXML::Element" do @@ -452,7 +452,7 @@ def sdf_model_in_model_that_replaces_pose_in_include assert SDF::XML.cached_model?("virtual_el") loaded = SDF::XML.model_from_name("virtual_el", flatten: false) - assert_equal element, loaded.root + assert_equal element.to_s, loaded.root.to_s end it "allows registering a model as a raw XML String" do @@ -471,6 +471,38 @@ def sdf_model_in_model_that_replaces_pose_in_include SDF::XML.register_in_memory_model("invalid_model", 12_345) end end + + it "falls back to the nil version cache if the requested version is not registered" do + doc = REXML::Document.new("") + # Register exclusively under nil (unversioned) cache by passing nil explicitly + SDF::XML.register_in_memory_model("virtual_fallback", doc, sdf_version: nil) + + # Requesting with specific version 160 should fall back and load successfully + loaded = SDF::XML.model_from_name("virtual_fallback", 160, flatten: false) + assert_equal "virtual_fallback", loaded.root.attributes["name"] + end + + it "resolves nested inclusions within in-memory models at registration time" do + submodel_doc = REXML::Document.new("") + SDF::XML.register_in_memory_model("submodel", submodel_doc) + + # Register a parent model containing an include to the submodel + parent_doc = REXML::Document.new( + "" \ + " " \ + " model://submodelincluded_sub" \ + " " \ + "" + ) + resolved_doc, metadata = SDF::XML.resolve_sdf_xml(parent_doc, flatten: false, metadata: true, path: "virtual://parent_model") + SDF::XML.register_in_memory_model("parent_model", resolved_doc, metadata: metadata) + + # Retrieve with flatten: true (which requires all inclusions to be resolved) + loaded = SDF::XML.model_from_name("parent_model", flatten: true) + + # Verify that the submodel's links are present in the flattened parent tree + assert loaded.elements["//link[@name='included_sub::sub_link']"] + end end it "raises if the model cannot be found" do exception = assert_raises(SDF::XML::NoSuchModel) do From c038db92d183c0f8bedce125302dd4bcaabffb04 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Fri, 31 Jul 2026 18:24:52 -0300 Subject: [PATCH 3/6] fix expected paths in test_xml.rb --- test/test_xml.rb | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/test/test_xml.rb b/test/test_xml.rb index 2d26b7f..7e9105b 100644 --- a/test/test_xml.rb +++ b/test/test_xml.rb @@ -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 From 0e9528c6753ec25357f000923eb2ac383cfb462d Mon Sep 17 00:00:00 2001 From: Gustavo Date: Wed, 5 Aug 2026 13:48:47 -0300 Subject: [PATCH 4/6] `cache_model?` -> `cache_model` --- lib/sdf/xml.rb | 2 +- test/test_xml.rb | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/sdf/xml.rb b/lib/sdf/xml.rb index 2b6ab7b..854607a 100644 --- a/lib/sdf/xml.rb +++ b/lib/sdf/xml.rb @@ -212,7 +212,7 @@ def self.register_in_memory_model(model_name, xml_doc, sdf_version: nil, metadat # # @param [String] model_name the target name # @return [Boolean] - def self.cached_model?(model_name, sdf_version: nil) + def self.cached_model(model_name, sdf_version: nil) # Check version-specific cache if entry = @gazebo_models.dig(sdf_version, model_name) return true if entry.xml diff --git a/test/test_xml.rb b/test/test_xml.rb index 7e9105b..9b30660 100644 --- a/test/test_xml.rb +++ b/test/test_xml.rb @@ -433,34 +433,34 @@ def sdf_model_in_model_that_replaces_pose_in_include end it "allows registering a model as a REXML::Document" do - refute SDF::XML.cached_model?("virtual_model") + refute SDF::XML.cached_model("virtual_model") doc = REXML::Document.new("") SDF::XML.register_in_memory_model("virtual_model", doc) - assert SDF::XML.cached_model?("virtual_model") + assert SDF::XML.cached_model("virtual_model") assert_equal doc.to_s, SDF::XML.model_from_name("virtual_model", flatten: false).to_s end it "allows registering a model as a REXML::Element" do - refute SDF::XML.cached_model?("virtual_el") + refute SDF::XML.cached_model("virtual_el") element = REXML::Element.new("model") element.add_attribute("name", "virtual") SDF::XML.register_in_memory_model("virtual_el", element) - assert SDF::XML.cached_model?("virtual_el") + assert SDF::XML.cached_model("virtual_el") loaded = SDF::XML.model_from_name("virtual_el", flatten: false) assert_equal element.to_s, loaded.root.to_s end it "allows registering a model as a raw XML String" do - refute SDF::XML.cached_model?("virtual_str") + refute SDF::XML.cached_model("virtual_str") xml_string = "" SDF::XML.register_in_memory_model("virtual_str", xml_string) - assert SDF::XML.cached_model?("virtual_str") + assert SDF::XML.cached_model("virtual_str") loaded = SDF::XML.model_from_name("virtual_str", flatten: false) assert_equal "virtual", loaded.root.attributes["name"] end @@ -487,10 +487,10 @@ def sdf_model_in_model_that_replaces_pose_in_include # Register a parent model containing an include to the submodel parent_doc = REXML::Document.new( - "" \ - " " \ - " model://submodelincluded_sub" \ - " " \ + " " \ + " " \ + "model://submodelincluded_sub " \ + "" \ "" ) resolved_doc, metadata = SDF::XML.resolve_sdf_xml(parent_doc, flatten: false, metadata: true, path: "virtual://parent_model") From f3e1d34337f6bca41f2fff5197c06e7ae42fc9c0 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Wed, 5 Aug 2026 13:57:45 -0300 Subject: [PATCH 5/6] lint --- lib/sdf/xml.rb | 37 ++++++++++++++++--------------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/lib/sdf/xml.rb b/lib/sdf/xml.rb index 854607a..e6fda45 100644 --- a/lib/sdf/xml.rb +++ b/lib/sdf/xml.rb @@ -40,10 +40,10 @@ def self.model_path # search for models def self.model_path=(path) new_path = Array(path) - if @model_path != new_path - @model_path = new_path - clear_cache - end + return unless @model_path != new_path + + @model_path = new_path + clear_cache end # load model_path with default parameters @@ -183,7 +183,7 @@ def self.register_in_memory_model(model_name, xml_doc, sdf_version: nil, metadat if sdf_version.nil? && xml.root && xml.root.name == "sdf" version_str = xml.root.attributes["version"] if version_str - sdf_version = (Float(version_str) * 100).to_i rescue nil + sdf_version = Float(version_str, exception: false)&.then { |f| (f * 100).to_i } end end @@ -198,14 +198,14 @@ def self.register_in_memory_model(model_name, xml_doc, sdf_version: nil, metadat cache.xml = xml cache.metadata = metadata + return unless sdf_version + # Also register under nil as a generic fallback - if sdf_version - @gazebo_models[nil] ||= {} - cache_nil = (@gazebo_models[nil][model_name] ||= ModelCacheEntry.new) - cache_nil.path = cache.path - cache_nil.xml = xml - cache_nil.metadata = metadata - end + @gazebo_models[nil] ||= {} + cache_nil = (@gazebo_models[nil][model_name] ||= ModelCacheEntry.new) + cache_nil.path = cache.path + cache_nil.xml = xml + cache_nil.metadata = metadata end # Checks if a model name is already cached in memory @@ -213,15 +213,10 @@ def self.register_in_memory_model(model_name, xml_doc, sdf_version: nil, metadat # @param [String] model_name the target name # @return [Boolean] def self.cached_model(model_name, sdf_version: nil) - # Check version-specific cache - if entry = @gazebo_models.dig(sdf_version, model_name) - return true if entry.xml - end - # Check fallback cache - if sdf_version && (entry = @gazebo_models.dig(nil, model_name)) - return true if entry.xml - end - false + name = model_name[%r{^model://(\w+)}, 1] || model_name + [sdf_version, nil].uniq + .filter_map { |version| @gazebo_models.dig(version, name) } + .find(&:xml) end # Finds the path to the SDF for a gazebo model and SDF version From 5f6189e48e0cc2b6b93114ec74594a448a7cbbe6 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Fri, 31 Jul 2026 18:23:59 -0300 Subject: [PATCH 6/6] improvements YARD docs --- lib/sdf/xml.rb | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/lib/sdf/xml.rb b/lib/sdf/xml.rb index e6fda45..ecba5d4 100644 --- a/lib/sdf/xml.rb +++ b/lib/sdf/xml.rb @@ -226,7 +226,7 @@ def self.cached_model(model_name, 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) @@ -282,6 +282,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? @@ -333,6 +350,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]