Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 127 additions & 8 deletions lib/sdf/xml.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ def self.model_path
# @param [Array<String>] path list of directories in which we should
# search for models
def self.model_path=(path)
@model_path = Array(path)
new_path = Array(path)
return unless @model_path != new_path

@model_path = new_path
clear_cache
end

Expand Down Expand Up @@ -158,19 +161,85 @@ def self.gazebo_models(sdf_version = nil)

ModelCacheEntry = Struct.new :path, :xml, :metadata

# 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
# @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
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, exception: false)&.then { |f| (f * 100).to_i }
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 = metadata["path"]
cache.xml = xml
cache.metadata = metadata

return unless sdf_version

# Also register under nil as a generic fallback
@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
#
# @param [String] model_name the target name
# @return [Boolean]
def self.cached_model(model_name, sdf_version: nil)
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
#
# @param [String] model_name the model name
# @!macro sdf_version
# @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)
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"))
Expand Down Expand Up @@ -213,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 `<uri>` tags to
# absolute paths on the local filesystem.
#
# It skips `<include>` tags because those are resolved separately during
# {.add_include_tags}.
#
# @example Replaces a model:// mesh path:
# # Before: <uri>model://robot_model/hull.dae</uri>
# # After: <uri>/path/to/workspace/robot_models/models/sdf/robot_model/hull.dae</uri>
#
# @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?
Expand Down Expand Up @@ -264,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:
# # <world name="my_world">
# # <include>
# # <uri>model://my_sensor</uri>
# # <name>custom_sensor</name>
# # <pose>1 0 0 0 0 0</pose>
# # </include>
# # </world>
# #
# # After calling add_include_tags:
# # <world name="my_world">
# # <model name="custom_sensor">
# # <pose>1 0 0 0 0 0</pose>
# # <link name="sensor_link">...</link>
# # </model>
# # </world>
#
# @param [REXML::Element] elem element to find include tags
# @!macro sdf_version
# @return [void]
Expand Down Expand Up @@ -448,25 +552,40 @@ 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
[sdf, sdf_metadata]
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
Expand Down
109 changes: 93 additions & 16 deletions test/test_xml.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 <uri> tags" do
sdf = SDF::XML.load_sdf(File.join(models_dir,
Expand All @@ -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 <uri> tags" do
sdf = SDF::XML.load_sdf(File.join(models_dir,
Expand All @@ -204,17 +206,16 @@ 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",
"w::model::model_in_model::child_of_model_in_model",
"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
Expand All @@ -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
Expand Down Expand Up @@ -426,6 +425,84 @@ 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("<model name='virtual'><link name='base'/></model>")
SDF::XML.register_in_memory_model("virtual_model", doc)

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")

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.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")

xml_string = "<model name='virtual'><link name='base'/></model>"
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

it "falls back to the nil version cache if the requested version is not registered" do
doc = REXML::Document.new("<model name='virtual_fallback'/>")
# 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 version='1.6'><model name='sub'><link name='sub_link'/></model></sdf>")
SDF::XML.register_in_memory_model("submodel", submodel_doc)

# Register a parent model containing an include to the submodel
parent_doc = REXML::Document.new(
"<sdf version='1.6'> " \
"<model name='parent'> " \
"<include><uri>model://submodel</uri><name>included_sub</name></include> " \
"</model>" \
"</sdf>"
)
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
SDF::XML.model_from_name("does_not_exist")
Expand Down