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
1 change: 1 addition & 0 deletions lib/sdf.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
require "sdf/plugin"
require "sdf/sensor"
require "sdf/frame"
require "sdf/loader"

# The toplevel namespace for sdf
#
Expand Down
56 changes: 56 additions & 0 deletions lib/sdf/erb_context.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# frozen_string_literal: true

module SDF
# class to represent the context for ERB evaluation
class ERBContext < BasicObject
class MissingArgumentError < ::ArgumentError; end

def initialize(args: {})
@args = deep_symbolize_keys(args)
end

def defaults(document)
@args = deep_merge(deep_symbolize_keys(document), @args)
end

# Recursively converts all hash keys to symbols
def deep_symbolize_keys(val)
return val unless val.kind_of?(::Hash)

val.transform_keys(&:to_sym).transform_values { |v| deep_symbolize_keys(v) }
end

# Recursively merges defaults with overrides
def deep_merge(defaults, overrides)
defaults.merge(overrides) do |_, oldval, newval|
if oldval.kind_of?(::Hash) && newval.kind_of?(::Hash)
deep_merge(oldval, newval)
else
newval
end
end
end

# rubocop:disable Style/OptionalBooleanParameter
def respond_to?(method_name, include_all = false)
sym = method_name.to_sym
return true if ERBContext.method_defined?(sym)
return true if include_all && ERBContext.private_method_defined?(sym)

@args.key?(method_name.to_sym)
end
# rubocop:enable Style/OptionalBooleanParameter

# rubocop:disable Style/MissingRespondToMissing
def method_missing(method_name, *)
if @args.key?(method_name)
val = @args[method_name]
return val.kind_of?(::Hash) ? ERBContext.new(args: val) : val
end
::Kernel.raise MissingArgumentError.new(
"no ERB argument available named '#{method_name}'"
)
end
# rubocop:enable Style/MissingRespondToMissing
end
end
21 changes: 21 additions & 0 deletions lib/sdf/exceptions.rb
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions lib/sdf/loader.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# frozen_string_literal: true
Comment thread
Rezenders marked this conversation as resolved.

require "erb"
require "sdf/erb_context"

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_context = SDF::ERBContext.new(args: @erb_args)
xml_string = ::ERB.new(xml_string, trim_mode: "-").result(
erb_context.instance_eval { ::Kernel.binding }
)
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
100 changes: 47 additions & 53 deletions lib/sdf/xml.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 `<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 +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:
# # <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 @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0"?>
<model>
<name>simple_model</name>
<sdf version="1.5">model.sdf</sdf>
</model>
42 changes: 42 additions & 0 deletions test/data/invalid_models/erb_model_with_sdf_extension/model.sdf
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?xml version="1.0" ?>
<%
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
%>
<sdf version="1.6">
<model name="simple_model_erb">
<link name="root">
<sensor name="g" type="gps" />
</link>
<link name="child" />
<joint name="roo2child" type="revolute">
<parent>root</parent>
<child>child</child>
<axis>
</axis>
</joint>

<link name="gps">
<pose><%= gps1_pose.join(' ') %></pose>
</link>
<joint name="gps_attachment" type="fixed">
<parent>root</parent>
<child>gps</child>
</joint>

<link name="gps2">
<pose><%= gps2_pose.join(' ') %></pose>
</link>
<joint name="gps2_attachment" type="fixed">
<parent>root</parent>
<child>gps2</child>
</joint>

<plugin name="gps_test">
<task model="rock_gazebo::GPSTask"/>
</plugin>
</model>
</sdf>
1 change: 0 additions & 1 deletion test/data/models/no_root.xml
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
<?xml version="1.0"?>

5 changes: 5 additions & 0 deletions test/data/models/simple_model_erb/model.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0"?>
<model>
<name>simple_model</name>
<sdf version="1.5">model.sdf.erb</sdf>
</model>
43 changes: 43 additions & 0 deletions test/data/models/simple_model_erb/model.sdf.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?xml version="1.0" ?>
<%
defaults({
poses: {
gps: [-0.679, 0.0, 1.920, 0.0, 0.0, 0.0],
gps2: [2.571, 0.044, 0.808, 0.0, 0.0, 0.0]
}
})
%>
<sdf version="1.6">
<model name="simple_model_erb">
<link name="root">
<sensor name="g" type="gps" />
</link>
<link name="child" />
<joint name="roo2child" type="revolute">
<parent>root</parent>
<child>child</child>
<axis>
</axis>
</joint>

<link name="gps">
<pose><%= poses.gps.join(' ') %></pose>
</link>
<joint name="gps_attachment" type="fixed">
<parent>root</parent>
<child>gps</child>
</joint>

<link name="gps2">
<pose><%= poses.gps2.join(' ') %></pose>
</link>
<joint name="gps2_attachment" type="fixed">
<parent>root</parent>
<child>gps2</child>
</joint>

<plugin name="gps_test">
<task model="rock_gazebo::GPSTask"/>
</plugin>
</model>
</sdf>
Loading