bullet3 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +194 -0
  4. data/data/cube.urdf +22 -0
  5. data/data/plane.urdf +19 -0
  6. data/ext/bullet3/CMakeLists.txt +224 -0
  7. data/ext/bullet3/bullet3.cpp +34 -0
  8. data/ext/bullet3/collision/rb_collision.cpp +428 -0
  9. data/ext/bullet3/collision/rb_collision.hpp +87 -0
  10. data/ext/bullet3/collision/shapes/rb_collision_shape.cpp +485 -0
  11. data/ext/bullet3/collision/shapes/rb_collision_shape.hpp +5 -0
  12. data/ext/bullet3/constraints/rb_constraints.cpp +1310 -0
  13. data/ext/bullet3/constraints/rb_constraints.hpp +226 -0
  14. data/ext/bullet3/dynamics/rb_dynamics.cpp +862 -0
  15. data/ext/bullet3/dynamics/rb_dynamics.hpp +180 -0
  16. data/ext/bullet3/extconf.rb +57 -0
  17. data/ext/bullet3/io/rb_io.cpp +108 -0
  18. data/ext/bullet3/io/rb_io.hpp +40 -0
  19. data/ext/bullet3/linear_math/rb_matrix3x3.cpp +93 -0
  20. data/ext/bullet3/linear_math/rb_matrix3x3.hpp +5 -0
  21. data/ext/bullet3/linear_math/rb_quaternion.cpp +134 -0
  22. data/ext/bullet3/linear_math/rb_quaternion.hpp +5 -0
  23. data/ext/bullet3/linear_math/rb_transform.cpp +157 -0
  24. data/ext/bullet3/linear_math/rb_transform.hpp +34 -0
  25. data/ext/bullet3/linear_math/rb_vector3.cpp +123 -0
  26. data/ext/bullet3/linear_math/rb_vector3.hpp +5 -0
  27. data/ext/bullet3/multi_body/rb_multi_body.cpp +1000 -0
  28. data/ext/bullet3/multi_body/rb_multi_body.hpp +190 -0
  29. data/ext/bullet3/soft_body/rb_soft_body.cpp +720 -0
  30. data/ext/bullet3/soft_body/rb_soft_body.hpp +122 -0
  31. data/ext/bullet3/util/rb_debug_draw.cpp +250 -0
  32. data/ext/bullet3/util/rb_debug_draw.hpp +47 -0
  33. data/ext/bullet3/util/ruby_cpp_compat.hpp +29 -0
  34. data/ext/bullet3/util/type_conversions.hpp +107 -0
  35. data/ext/bullet3/vehicle/rb_vehicle.cpp +467 -0
  36. data/ext/bullet3/vehicle/rb_vehicle.hpp +115 -0
  37. data/lib/bullet3/collision.rb +6 -0
  38. data/lib/bullet3/constraints.rb +6 -0
  39. data/lib/bullet3/data.rb +39 -0
  40. data/lib/bullet3/debug_draw.rb +73 -0
  41. data/lib/bullet3/dynamics.rb +4 -0
  42. data/lib/bullet3/io.rb +110 -0
  43. data/lib/bullet3/linear_math/matrix3x3.rb +69 -0
  44. data/lib/bullet3/linear_math/quaternion.rb +187 -0
  45. data/lib/bullet3/linear_math/transform.rb +48 -0
  46. data/lib/bullet3/linear_math/vector3.rb +199 -0
  47. data/lib/bullet3/linear_math.rb +6 -0
  48. data/lib/bullet3/multi_body.rb +6 -0
  49. data/lib/bullet3/simulation.rb +1170 -0
  50. data/lib/bullet3/soft_body.rb +6 -0
  51. data/lib/bullet3/vehicle.rb +4 -0
  52. data/lib/bullet3/version.rb +5 -0
  53. data/lib/bullet3.rb +49 -0
  54. metadata +124 -0
@@ -0,0 +1,1170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rexml/document"
5
+
6
+ module Bullet3
7
+ class Simulation
8
+ attr_reader :world, :mode
9
+
10
+ def initialize(mode: :direct)
11
+ mode = mode.to_sym
12
+ raise ArgumentError, "unsupported connection mode: #{mode}" unless %i[direct gui shared_memory].include?(mode)
13
+ Bullet3.require_native_extension!("Bullet3::Simulation") unless Bullet3.native?
14
+
15
+ @mode = mode
16
+ @world = DiscreteDynamicsWorld.create
17
+ @shapes = []
18
+ @shape_specs = []
19
+ @bodies = []
20
+ @body_specs = []
21
+ @constraints = []
22
+ @constraint_specs = []
23
+ @models = {}
24
+ @importers = []
25
+ end
26
+
27
+ def set_gravity(x, y = nil, z = nil)
28
+ vector = vector_argument(x, y, z)
29
+ world.gravity = vector
30
+ end
31
+
32
+ def step_simulation(time_step: 1.0 / 240.0, max_sub_steps: 1, fixed_time_step: 1.0 / 240.0)
33
+ world.step_simulation(time_step, max_sub_steps, fixed_time_step)
34
+ end
35
+
36
+ def create_collision_shape(type, **options)
37
+ shape = build_collision_shape(type.to_sym, options)
38
+ register_shape(shape, type: type.to_sym, options: serializable_options(options))
39
+ end
40
+
41
+ def create_rigid_body(mass:, collision_shape:, position: [0, 0, 0], orientation: Quaternion.identity, rgba_color: [220, 220, 220, 255])
42
+ shape = shape_for(collision_shape)
43
+ position_vector = Vector3.coerce(position)
44
+ orientation_quaternion = Quaternion.coerce(orientation)
45
+ transform = Transform.new(orientation_quaternion, position_vector)
46
+ motion_state = MotionState.new(transform)
47
+ info = RigidBodyConstructionInfo.new(Float(mass), motion_state, shape)
48
+ body = RigidBody.new(info)
49
+ world.add_rigid_body(body)
50
+
51
+ @bodies << body
52
+ @body_specs << {
53
+ mass: Float(mass),
54
+ collision_shape: Integer(collision_shape),
55
+ position: position_vector.to_a,
56
+ orientation: orientation_quaternion.to_a,
57
+ rgba_color: rgba_color_argument(rgba_color)
58
+ }
59
+ @bodies.length - 1
60
+ end
61
+
62
+ def create_constraint(type, **options)
63
+ constraint = build_constraint(type.to_sym, options)
64
+ world.add_constraint(constraint, options.fetch(:disable_collisions_between_linked_bodies, false))
65
+
66
+ @constraints << constraint
67
+ @constraint_specs << { type: type.to_sym, options: serializable_options(options) }
68
+ @constraints.length - 1
69
+ end
70
+
71
+ def load_urdf(filename, base_position: [0, 0, 0], base_orientation: Quaternion.identity, use_fixed_base: false, global_scaling: 1.0)
72
+ path = Data.find(filename)
73
+ document = REXML::Document.new(File.read(path))
74
+ links = REXML::XPath.match(document, "/robot/link")
75
+ joints = REXML::XPath.match(document, "/robot/joint")
76
+ raise ArgumentError, "URDF must contain at least one link: #{filename}" if links.empty?
77
+ return load_multilink_urdf(links, joints, base_position, base_orientation, use_fixed_base, Float(global_scaling), File.dirname(path)) if links.length > 1 || joints.any?
78
+
79
+ link = links.first
80
+ mass = use_fixed_base ? 0.0 : urdf_link_mass(link)
81
+ shape = urdf_collision_shape(link, Float(global_scaling), File.dirname(path))
82
+ shape_id = register_shape(shape, urdf_shape_spec(link, Float(global_scaling), File.dirname(path)))
83
+ body_id = create_rigid_body(
84
+ mass: mass,
85
+ collision_shape: shape_id,
86
+ position: base_position,
87
+ orientation: base_orientation
88
+ )
89
+ apply_urdf_contact(link, body_for(body_id))
90
+ body_id
91
+ end
92
+
93
+ def load_sdf(filename, base_position: [0, 0, 0], base_orientation: Quaternion.identity, global_scaling: 1.0)
94
+ path = Data.find(filename)
95
+ document = REXML::Document.new(File.read(path))
96
+ model_elements = REXML::XPath.match(document, "//model")
97
+ raise ArgumentError, "SDF must contain at least one model: #{filename}" if model_elements.empty?
98
+
99
+ model_elements.flat_map do |model|
100
+ load_sdf_model(model, base_position, base_orientation, Float(global_scaling), File.dirname(path))
101
+ end
102
+ end
103
+
104
+ def load_mjcf(filename, base_position: [0, 0, 0], base_orientation: Quaternion.identity, global_scaling: 1.0)
105
+ path = Data.find(filename)
106
+ document = REXML::Document.new(File.read(path))
107
+ body_elements = REXML::XPath.match(document, "/mujoco/worldbody/body")
108
+ raise ArgumentError, "MJCF must contain at least one worldbody body: #{filename}" if body_elements.empty?
109
+
110
+ base_transform = Transform.new(Quaternion.coerce(base_orientation), Vector3.coerce(base_position))
111
+ body_elements.flat_map do |body|
112
+ load_mjcf_body(body, base_transform, Float(global_scaling), File.dirname(path))
113
+ end
114
+ end
115
+
116
+ def get_base_position_and_orientation(body_id)
117
+ transform = body_for(body_id).world_transform
118
+ [transform.origin.to_a, transform.rotation.to_a]
119
+ end
120
+
121
+ def set_base_position_and_orientation(body_id, position, orientation = Quaternion.identity)
122
+ body = body_for(body_id)
123
+ body.world_transform = Transform.new(Quaternion.coerce(orientation), Vector3.coerce(position))
124
+ nil
125
+ end
126
+
127
+ def get_aabb(body_id)
128
+ body = body_for(body_id)
129
+ body.collision_shape.aabb(body.world_transform).map(&:to_a)
130
+ end
131
+
132
+ def ray_test(from, to)
133
+ world.ray_test(from, to)
134
+ end
135
+
136
+ def ray_test_all(from, to)
137
+ world.ray_test_all(from, to)
138
+ end
139
+
140
+ def ray_test_batch(rays)
141
+ rays.map do |ray|
142
+ from, to = ray
143
+ ray_test(from, to)
144
+ end
145
+ end
146
+
147
+ def get_contact_points(body_a: nil, body_b: nil)
148
+ contacts = if body_a && body_b
149
+ world.contact_pair_test(body_for(body_a), body_for(body_b))
150
+ elsif body_a
151
+ world.contact_test(body_for(body_a))
152
+ elsif body_b
153
+ world.contact_test(body_for(body_b))
154
+ else
155
+ manifold_contacts
156
+ end
157
+
158
+ contacts.map { |contact| contact_with_body_ids(contact) }
159
+ end
160
+
161
+ def change_dynamics(body_id, lateral_friction: nil, restitution: nil)
162
+ body = body_for(body_id)
163
+ body.friction = Float(lateral_friction) unless lateral_friction.nil?
164
+ body.restitution = Float(restitution) unless restitution.nil?
165
+ nil
166
+ end
167
+
168
+ def get_num_joints(body_id)
169
+ model_for(body_id)[:joints].length
170
+ end
171
+
172
+ def get_joint_info(body_id, joint_index)
173
+ model_for(body_id)[:joints].fetch(Integer(joint_index)) { raise ArgumentError, "unknown joint index: #{joint_index}" }.dup
174
+ end
175
+
176
+ def get_joint_state(body_id, joint_index)
177
+ joint = get_joint_info(body_id, joint_index)
178
+ constraint = joint[:constraint_id] && constraint_for(joint[:constraint_id])
179
+ position = joint_position(joint, constraint)
180
+ {
181
+ position: position,
182
+ velocity: joint[:target_velocity] || 0.0,
183
+ reaction_forces: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
184
+ applied_torque: joint[:applied_torque] || 0.0
185
+ }
186
+ end
187
+
188
+ def set_joint_motor_control(body_id, joint_index, control_mode:, target_position: nil, target_velocity: 0.0, force: 0.0)
189
+ model = model_for(body_id)
190
+ joint = model[:joints].fetch(Integer(joint_index)) { raise ArgumentError, "unknown joint index: #{joint_index}" }
191
+ constraint = joint[:constraint_id] && constraint_for(joint[:constraint_id])
192
+ mode = control_mode.to_sym
193
+
194
+ case mode
195
+ when :velocity
196
+ set_velocity_control(joint, constraint, Float(target_velocity), Float(force))
197
+ when :position
198
+ set_position_control(joint, constraint, Float(target_position || 0.0), Float(force))
199
+ when :torque
200
+ set_torque_control(joint, Float(force))
201
+ else
202
+ raise ArgumentError, "unsupported joint control mode: #{control_mode}"
203
+ end
204
+
205
+ nil
206
+ end
207
+
208
+ def get_camera_image(width, height, camera_eye_position: [0, 0, 5], camera_target_position: [0, 0, 0], camera_up_vector: [0, 1, 0], fov: 60.0, near: 0.01, far: 100.0, background_color: [0, 0, 0, 255], body_colors: {})
209
+ width = Integer(width)
210
+ height = Integer(height)
211
+ raise ArgumentError, "width and height must be positive" unless width.positive? && height.positive?
212
+
213
+ camera = camera_basis(camera_eye_position, camera_target_position, camera_up_vector, Float(fov), Float(far))
214
+ background_color = rgba_color_argument(background_color)
215
+ body_colors = body_colors.to_h { |body_id, color| [Integer(body_id), rgba_color_argument(color)] }
216
+ rgb = []
217
+ depth = []
218
+ segmentation = []
219
+
220
+ height.times do |row|
221
+ width.times do |column|
222
+ hit = camera_ray_hit(camera, column, row, width, height)
223
+ append_camera_pixel(hit, rgb, depth, segmentation, background_color, body_colors)
224
+ end
225
+ end
226
+
227
+ [width, height, rgb, depth, segmentation]
228
+ end
229
+
230
+ def debug_draw_world(drawer = DebugDraw.new, mode: nil)
231
+ drawer.debug_mode = Integer(mode) if mode
232
+
233
+ if world.respond_to?(:debug_drawer=) && world.respond_to?(:debug_draw_world)
234
+ world.debug_drawer = drawer
235
+ world.debug_draw_world
236
+ else
237
+ draw_world_fallback(drawer)
238
+ end
239
+
240
+ drawer
241
+ end
242
+
243
+ def save_world(filename)
244
+ File.write(filename, JSON.pretty_generate(world_snapshot))
245
+ filename
246
+ end
247
+
248
+ def save_bullet(filename)
249
+ serializer = IO::Serializer.new(self)
250
+ serializer.save_bullet(self, filename)
251
+ end
252
+
253
+ def load_world(filename)
254
+ data = JSON.parse(File.read(filename), symbolize_names: true)
255
+ reset_simulation
256
+
257
+ data.fetch(:shapes).each { |shape| create_collision_shape(shape.fetch(:type), **symbolize_hash(shape.fetch(:options))) }
258
+ data.fetch(:bodies).each do |body|
259
+ next if body.nil?
260
+
261
+ create_rigid_body(
262
+ mass: body.fetch(:mass),
263
+ collision_shape: body.fetch(:collision_shape),
264
+ position: body.fetch(:position),
265
+ orientation: body.fetch(:orientation),
266
+ rgba_color: body.fetch(:rgba_color, [220, 220, 220, 255])
267
+ )
268
+ end
269
+ data.fetch(:constraints, []).each do |constraint|
270
+ next if constraint.nil?
271
+
272
+ create_constraint(constraint.fetch(:type), **symbolize_hash(constraint.fetch(:options)))
273
+ end
274
+
275
+ nil
276
+ end
277
+
278
+ def load_bullet(filename)
279
+ raise Bullet3::Error, "native extension is required for .bullet import" unless IO.const_defined?(:BulletWorldImporter, false)
280
+
281
+ importer = IO::BulletWorldImporter.new(world)
282
+ raise Bullet3::Error, "failed to load .bullet file: #{filename}" unless importer.load_file(filename)
283
+
284
+ @importers << importer
285
+ importer
286
+ end
287
+
288
+ def reset_simulation
289
+ clear_importers
290
+ @constraints.compact.each { |constraint| world.remove_constraint(constraint) }
291
+ @bodies.compact.each { |body| world.remove_rigid_body(body) }
292
+ @world = DiscreteDynamicsWorld.create
293
+ @bodies.clear
294
+ @body_specs.clear
295
+ @shapes.clear
296
+ @shape_specs.clear
297
+ @constraints.clear
298
+ @constraint_specs.clear
299
+ @models.clear
300
+ nil
301
+ end
302
+
303
+ def body(body_id)
304
+ body_for(body_id)
305
+ end
306
+
307
+ def collision_shape(shape_id)
308
+ shape_for(shape_id)
309
+ end
310
+
311
+ def constraint(constraint_id)
312
+ constraint_for(constraint_id)
313
+ end
314
+
315
+ def remove_body(body_id)
316
+ body = body_for(body_id)
317
+ world.remove_rigid_body(body)
318
+ @bodies[Integer(body_id)] = nil
319
+ @body_specs[Integer(body_id)] = nil
320
+ nil
321
+ end
322
+
323
+ def remove_constraint(constraint_id)
324
+ constraint = constraint_for(constraint_id)
325
+ world.remove_constraint(constraint)
326
+ @constraints[Integer(constraint_id)] = nil
327
+ @constraint_specs[Integer(constraint_id)] = nil
328
+ nil
329
+ end
330
+
331
+ def disconnect
332
+ clear_importers
333
+ @constraints.compact.each { |constraint| world.remove_constraint(constraint) }
334
+ @bodies.compact.each { |body| world.remove_rigid_body(body) }
335
+ @constraints.clear
336
+ @constraint_specs.clear
337
+ @bodies.clear
338
+ @body_specs.clear
339
+ @shapes.clear
340
+ @shape_specs.clear
341
+ @models.clear
342
+ nil
343
+ end
344
+
345
+ private
346
+
347
+ def body_for(body_id)
348
+ @bodies.fetch(Integer(body_id)) { raise ArgumentError, "unknown body id: #{body_id}" } ||
349
+ raise(ArgumentError, "unknown body id: #{body_id}")
350
+ end
351
+
352
+ def body_id_for(body)
353
+ return nil unless body
354
+
355
+ @bodies.index(body)
356
+ end
357
+
358
+ def shape_for(shape_id)
359
+ @shapes.fetch(Integer(shape_id)) { raise ArgumentError, "unknown collision shape id: #{shape_id}" } ||
360
+ raise(ArgumentError, "unknown collision shape id: #{shape_id}")
361
+ end
362
+
363
+ def constraint_for(constraint_id)
364
+ @constraints.fetch(Integer(constraint_id)) { raise ArgumentError, "unknown constraint id: #{constraint_id}" } ||
365
+ raise(ArgumentError, "unknown constraint id: #{constraint_id}")
366
+ end
367
+
368
+ def model_for(body_id)
369
+ @models.fetch(Integer(body_id)) { raise ArgumentError, "body has no joints: #{body_id}" }
370
+ end
371
+
372
+ def register_shape(shape, spec = nil)
373
+ @shapes << shape
374
+ @shape_specs << spec
375
+ @shapes.length - 1
376
+ end
377
+
378
+ def vector_argument(x, y, z)
379
+ return Vector3.coerce(x) if y.nil? && z.nil?
380
+
381
+ Vector3.new(x, y, z)
382
+ end
383
+
384
+ def rgba_color_argument(value)
385
+ values = value.to_a
386
+ raise ArgumentError, "RGBA color must contain four components" unless values.length == 4
387
+
388
+ values.map { |component| Integer(component).clamp(0, 255) }
389
+ end
390
+
391
+ def build_collision_shape(type, options)
392
+ case type
393
+ when :box
394
+ Shapes::BoxShape.new(Vector3.coerce(options.fetch(:half_extents)))
395
+ when :sphere
396
+ Shapes::SphereShape.new(Float(options.fetch(:radius)))
397
+ when :capsule
398
+ Shapes::CapsuleShape.new(Float(options.fetch(:radius)), Float(options.fetch(:height)))
399
+ when :cylinder
400
+ Shapes::CylinderShape.new(Vector3.coerce(options.fetch(:half_extents)))
401
+ when :cone
402
+ Shapes::ConeShape.new(Float(options.fetch(:radius)), Float(options.fetch(:height)))
403
+ when :plane, :static_plane
404
+ Shapes::StaticPlaneShape.new(Vector3.coerce(options.fetch(:normal, [0, 1, 0])), Float(options.fetch(:offset, 0.0)))
405
+ when :mesh, :triangle_mesh
406
+ triangles = options[:triangles] || load_obj_triangles(Data.find(options.fetch(:filename)), vector_scale(options[:scale], 1.0))
407
+ Shapes::TriangleMeshShape.new(triangles)
408
+ else
409
+ raise ArgumentError, "unsupported collision shape: #{type}"
410
+ end
411
+ end
412
+
413
+ def build_constraint(type, options)
414
+ body_a = body_for(options.fetch(:body_a))
415
+ body_b = options.key?(:body_b) && !options[:body_b].nil? ? body_for(options[:body_b]) : nil
416
+
417
+ case type
418
+ when :point2point, :p2p
419
+ build_point2point_constraint(body_a, body_b, options)
420
+ when :hinge
421
+ build_hinge_constraint(body_a, body_b, options)
422
+ when :fixed
423
+ require_body_b!(body_b, type)
424
+ Constraints::FixedConstraint.new(body_a, body_b, transform_option(options, :frame_in_a), transform_option(options, :frame_in_b))
425
+ when :slider
426
+ build_frame_constraint(Constraints::SliderConstraint, body_a, body_b, options)
427
+ when :cone_twist
428
+ build_frame_constraint(Constraints::ConeTwistConstraint, body_a, body_b, options)
429
+ when :generic_6dof
430
+ build_frame_constraint(Constraints::Generic6DofConstraint, body_a, body_b, options)
431
+ when :generic_6dof_spring2
432
+ build_frame_constraint(Constraints::Generic6DofSpring2Constraint, body_a, body_b, options, options.fetch(:rotate_order, 0))
433
+ when :gear
434
+ require_body_b!(body_b, type)
435
+ Constraints::GearConstraint.new(
436
+ body_a,
437
+ body_b,
438
+ options.fetch(:axis_in_a, [0, 1, 0]),
439
+ options.fetch(:axis_in_b, [0, 1, 0]),
440
+ Float(options.fetch(:ratio, 1.0))
441
+ )
442
+ when :hinge2
443
+ require_body_b!(body_b, type)
444
+ Constraints::Hinge2Constraint.new(
445
+ body_a,
446
+ body_b,
447
+ options.fetch(:anchor, [0, 0, 0]),
448
+ options.fetch(:axis_in_a, [0, 1, 0]),
449
+ options.fetch(:axis_in_b, [1, 0, 0])
450
+ )
451
+ else
452
+ raise ArgumentError, "unsupported constraint type: #{type}"
453
+ end
454
+ end
455
+
456
+ def build_point2point_constraint(body_a, body_b, options)
457
+ pivot_a = options.fetch(:pivot_in_a, [0, 0, 0])
458
+ return Constraints::Point2PointConstraint.new(body_a, pivot_a) unless body_b
459
+
460
+ Constraints::Point2PointConstraint.new(body_a, body_b, pivot_a, options.fetch(:pivot_in_b, [0, 0, 0]))
461
+ end
462
+
463
+ def build_hinge_constraint(body_a, body_b, options)
464
+ pivot_a = options.fetch(:pivot_in_a, [0, 0, 0])
465
+ axis_a = options.fetch(:axis_in_a, [0, 1, 0])
466
+ use_reference_frame_a = options.fetch(:use_reference_frame_a, false)
467
+ return Constraints::HingeConstraint.new(body_a, pivot_a, axis_a, use_reference_frame_a) unless body_b
468
+
469
+ Constraints::HingeConstraint.new(
470
+ body_a,
471
+ body_b,
472
+ pivot_a,
473
+ options.fetch(:pivot_in_b, [0, 0, 0]),
474
+ axis_a,
475
+ options.fetch(:axis_in_b, [0, 1, 0]),
476
+ use_reference_frame_a
477
+ )
478
+ end
479
+
480
+ def build_frame_constraint(klass, body_a, body_b, options, *extra_args)
481
+ frame_a = transform_option(options, :frame_in_a)
482
+ return klass.new(body_a, frame_a, *extra_args) unless body_b
483
+
484
+ klass.new(body_a, body_b, frame_a, transform_option(options, :frame_in_b), *extra_args)
485
+ end
486
+
487
+ def transform_option(options, key)
488
+ value = options.fetch(key, Transform.identity)
489
+ return value if value.is_a?(Transform)
490
+
491
+ Transform.new(Quaternion.coerce(value.fetch(:orientation, Quaternion.identity)), Vector3.coerce(value.fetch(:position, [0, 0, 0])))
492
+ end
493
+
494
+ def require_body_b!(body_b, type)
495
+ raise ArgumentError, "#{type} constraint requires body_b" unless body_b
496
+ end
497
+
498
+ def load_multilink_urdf(links, joints, base_position, base_orientation, use_fixed_base, global_scaling, base_path)
499
+ links_by_name = links.to_h { |link| [link.attributes["name"], link] }
500
+ root_name = urdf_root_link_name(links_by_name, joints)
501
+ base_transform = Transform.new(Quaternion.coerce(base_orientation), Vector3.coerce(base_position))
502
+ link_transforms = { root_name => base_transform }
503
+ link_body_ids = {
504
+ root_name => create_urdf_link_body(links_by_name.fetch(root_name), base_transform, use_fixed_base, true, global_scaling, base_path)
505
+ }
506
+ joint_infos = []
507
+ pending_joints = joints.dup
508
+
509
+ until pending_joints.empty?
510
+ progressed = false
511
+ pending_joints.delete_if do |joint|
512
+ parent_name = urdf_joint_parent(joint)
513
+ child_name = urdf_joint_child(joint)
514
+ next false unless link_body_ids[parent_name]
515
+
516
+ origin = urdf_origin_transform(REXML::XPath.first(joint, "origin"), global_scaling)
517
+ child_transform = link_transforms.fetch(parent_name) * origin
518
+ link_transforms[child_name] = child_transform
519
+ link_body_ids[child_name] = create_urdf_link_body(links_by_name.fetch(child_name), child_transform, false, false, global_scaling, base_path)
520
+ joint_infos << create_urdf_joint(joint, link_body_ids.fetch(parent_name), link_body_ids.fetch(child_name), origin)
521
+ progressed = true
522
+ end
523
+
524
+ raise ArgumentError, "URDF joint graph is disconnected or cyclic" unless progressed
525
+ end
526
+
527
+ base_id = link_body_ids.fetch(root_name)
528
+ @models[base_id] = {
529
+ links: link_body_ids.keys,
530
+ body_ids: link_body_ids.values,
531
+ joints: joint_infos
532
+ }
533
+ base_id
534
+ end
535
+
536
+ def urdf_root_link_name(links_by_name, joints)
537
+ child_names = joints.map { |joint| urdf_joint_child(joint) }
538
+ (links_by_name.keys - child_names).first || links_by_name.keys.first
539
+ end
540
+
541
+ def urdf_joint_parent(joint)
542
+ REXML::XPath.first(joint, "parent").attributes["link"]
543
+ end
544
+
545
+ def urdf_joint_child(joint)
546
+ REXML::XPath.first(joint, "child").attributes["link"]
547
+ end
548
+
549
+ def create_urdf_link_body(link, transform, use_fixed_base, root_link, global_scaling, base_path)
550
+ shape = urdf_link_collision_shape(link, global_scaling, base_path)
551
+ shape_id = register_shape(shape, urdf_shape_spec(link, global_scaling, base_path))
552
+ body_id = create_rigid_body(
553
+ mass: use_fixed_base && root_link ? 0.0 : urdf_link_mass(link),
554
+ collision_shape: shape_id,
555
+ position: transform.origin.to_a,
556
+ orientation: transform.rotation.to_a
557
+ )
558
+ apply_urdf_contact(link, body_for(body_id))
559
+ body_id
560
+ end
561
+
562
+ def urdf_link_collision_shape(link, global_scaling, base_path)
563
+ urdf_collision_shape(link, global_scaling, base_path)
564
+ rescue ArgumentError => error
565
+ raise unless error.message.include?("no collision geometry")
566
+
567
+ Shapes::SphereShape.new(0.001 * global_scaling)
568
+ end
569
+
570
+ def create_urdf_joint(joint, parent_body_id, child_body_id, origin)
571
+ type = joint.attributes["type"].to_s
572
+ axis = urdf_joint_axis(joint)
573
+ limits = urdf_joint_limits(joint)
574
+ constraint_id = case type
575
+ when "fixed"
576
+ create_constraint(:fixed, body_a: parent_body_id, body_b: child_body_id, frame_in_a: origin, frame_in_b: Transform.identity)
577
+ when "revolute", "continuous"
578
+ id = create_constraint(
579
+ :hinge,
580
+ body_a: parent_body_id,
581
+ body_b: child_body_id,
582
+ pivot_in_a: origin.origin.to_a,
583
+ pivot_in_b: [0, 0, 0],
584
+ axis_in_a: axis,
585
+ axis_in_b: axis
586
+ )
587
+ constraint(id).set_limit(limits[:lower], limits[:upper]) if type == "revolute" && limits[:lower] && limits[:upper]
588
+ id
589
+ when "prismatic"
590
+ id = create_constraint(:slider, body_a: parent_body_id, body_b: child_body_id, frame_in_a: origin, frame_in_b: Transform.identity)
591
+ constraint(id).lower_linear_limit = limits[:lower] if limits[:lower]
592
+ constraint(id).upper_linear_limit = limits[:upper] if limits[:upper]
593
+ id
594
+ else
595
+ create_constraint(:fixed, body_a: parent_body_id, body_b: child_body_id, frame_in_a: origin, frame_in_b: Transform.identity)
596
+ end
597
+
598
+ {
599
+ name: joint.attributes["name"].to_s,
600
+ type: type.empty? ? "fixed" : type,
601
+ parent_body: parent_body_id,
602
+ child_body: child_body_id,
603
+ constraint_id: constraint_id,
604
+ axis: axis,
605
+ lower_limit: limits[:lower],
606
+ upper_limit: limits[:upper],
607
+ target_velocity: 0.0,
608
+ applied_torque: 0.0
609
+ }
610
+ end
611
+
612
+ def urdf_joint_axis(joint)
613
+ axis = REXML::XPath.first(joint, "axis")
614
+ axis ? float_list(axis.attributes["xyz"]) : [1.0, 0.0, 0.0]
615
+ end
616
+
617
+ def urdf_joint_limits(joint)
618
+ limit = REXML::XPath.first(joint, "limit")
619
+ return {} unless limit
620
+
621
+ {
622
+ lower: limit.attributes["lower"] && Float(limit.attributes["lower"]),
623
+ upper: limit.attributes["upper"] && Float(limit.attributes["upper"]),
624
+ effort: limit.attributes["effort"] && Float(limit.attributes["effort"]),
625
+ velocity: limit.attributes["velocity"] && Float(limit.attributes["velocity"])
626
+ }
627
+ end
628
+
629
+ def joint_position(joint, constraint)
630
+ return 0.0 unless constraint
631
+
632
+ case joint[:type]
633
+ when "revolute", "continuous"
634
+ constraint.angle
635
+ when "prismatic"
636
+ constraint.linear_position
637
+ else
638
+ 0.0
639
+ end
640
+ end
641
+
642
+ def set_velocity_control(joint, constraint, target_velocity, force)
643
+ joint[:target_velocity] = target_velocity
644
+ joint[:applied_torque] = force
645
+ case joint[:type]
646
+ when "revolute", "continuous"
647
+ constraint&.enable_angular_motor(true, target_velocity, force)
648
+ when "prismatic"
649
+ constraint.powered_linear_motor = true if constraint
650
+ constraint.target_linear_motor_velocity = target_velocity if constraint
651
+ constraint.max_linear_motor_force = force if constraint
652
+ end
653
+ end
654
+
655
+ def set_position_control(joint, constraint, target_position, force)
656
+ joint[:target_velocity] = 0.0
657
+ joint[:applied_torque] = force
658
+ case joint[:type]
659
+ when "revolute", "continuous"
660
+ constraint&.set_limit(target_position, target_position)
661
+ constraint&.enable_angular_motor(true, 0.0, force)
662
+ when "prismatic"
663
+ if constraint
664
+ constraint.lower_linear_limit = target_position
665
+ constraint.upper_linear_limit = target_position
666
+ constraint.max_linear_motor_force = force
667
+ end
668
+ end
669
+ end
670
+
671
+ def set_torque_control(joint, force)
672
+ joint[:applied_torque] = force
673
+ axis = joint[:axis]
674
+ body_for(joint.fetch(:child_body)).apply_torque(axis.map { |component| component * force })
675
+ end
676
+
677
+ def load_sdf_model(model, base_position, base_orientation, global_scaling, base_path)
678
+ model_transform = Transform.new(Quaternion.coerce(base_orientation), Vector3.coerce(base_position)) *
679
+ sdf_pose_transform(REXML::XPath.first(model, "pose"), global_scaling)
680
+
681
+ REXML::XPath.match(model, "link").map do |link|
682
+ link_transform = model_transform * sdf_pose_transform(REXML::XPath.first(link, "pose"), global_scaling)
683
+ shape = sdf_link_collision_shape(link, global_scaling, base_path)
684
+ shape_id = register_shape(shape)
685
+ create_rigid_body(
686
+ mass: sdf_link_mass(link),
687
+ collision_shape: shape_id,
688
+ position: link_transform.origin.to_a,
689
+ orientation: link_transform.rotation.to_a
690
+ )
691
+ end
692
+ end
693
+
694
+ def sdf_link_mass(link)
695
+ mass = REXML::XPath.first(link, "inertial/mass")
696
+ mass ? Float(mass.text) : 0.0
697
+ end
698
+
699
+ def sdf_link_collision_shape(link, global_scaling, base_path)
700
+ collisions = REXML::XPath.match(link, "collision")
701
+ raise ArgumentError, "SDF link has no collision geometry" if collisions.empty?
702
+
703
+ shapes = collisions.map do |collision|
704
+ geometry = REXML::XPath.first(collision, "geometry")
705
+ [sdf_geometry_shape(geometry, global_scaling, base_path), sdf_pose_transform(REXML::XPath.first(collision, "pose"), global_scaling)]
706
+ end
707
+ return shapes.first.first if shapes.length == 1 && identity_transform?(shapes.first.last)
708
+
709
+ compound = Shapes::CompoundShape.new
710
+ shapes.each { |shape, transform| compound.add_child_shape(transform, shape) }
711
+ compound
712
+ end
713
+
714
+ def sdf_geometry_shape(geometry, global_scaling, base_path)
715
+ if (box = REXML::XPath.first(geometry, "box"))
716
+ half_extents = float_list(REXML::XPath.first(box, "size").text).map { |value| value * global_scaling * 0.5 }
717
+ return Shapes::BoxShape.new(Vector3.coerce(half_extents))
718
+ end
719
+ if (sphere = REXML::XPath.first(geometry, "sphere"))
720
+ return Shapes::SphereShape.new(Float(REXML::XPath.first(sphere, "radius").text) * global_scaling)
721
+ end
722
+ if (cylinder = REXML::XPath.first(geometry, "cylinder"))
723
+ radius = Float(REXML::XPath.first(cylinder, "radius").text) * global_scaling
724
+ length = Float(REXML::XPath.first(cylinder, "length").text) * global_scaling
725
+ return Shapes::CylinderShape.new(Vector3.new(radius, length * 0.5, radius))
726
+ end
727
+ if (plane = REXML::XPath.first(geometry, "plane"))
728
+ normal = REXML::XPath.first(plane, "normal")&.text || "0 0 1"
729
+ return Shapes::StaticPlaneShape.new(Vector3.coerce(float_list(normal)), 0.0)
730
+ end
731
+ if (mesh = REXML::XPath.first(geometry, "mesh"))
732
+ uri = REXML::XPath.first(mesh, "uri").text
733
+ scale = vector_scale(REXML::XPath.first(mesh, "scale")&.text, global_scaling)
734
+ return Shapes::TriangleMeshShape.new(load_obj_triangles(resolve_mesh_filename(uri, base_path), scale))
735
+ end
736
+
737
+ raise ArgumentError, "unsupported SDF collision geometry"
738
+ end
739
+
740
+ def sdf_pose_transform(pose, global_scaling)
741
+ return Transform.identity unless pose
742
+
743
+ values = float_list(pose.text)
744
+ xyz = [values[0] || 0.0, values[1] || 0.0, values[2] || 0.0].map { |value| value * global_scaling }
745
+ rpy = [values[3] || 0.0, values[4] || 0.0, values[5] || 0.0]
746
+ Transform.new(Quaternion.from_euler(*rpy), Vector3.coerce(xyz))
747
+ end
748
+
749
+ def load_mjcf_body(body_element, parent_transform, global_scaling, base_path)
750
+ body_transform = parent_transform * mjcf_body_transform(body_element, global_scaling)
751
+ body_ids = []
752
+ geoms = REXML::XPath.match(body_element, "geom")
753
+ unless geoms.empty?
754
+ shape = mjcf_geoms_shape(geoms, global_scaling, base_path)
755
+ shape_id = register_shape(shape)
756
+ body_ids << create_rigid_body(
757
+ mass: mjcf_body_mass(body_element, geoms),
758
+ collision_shape: shape_id,
759
+ position: body_transform.origin.to_a,
760
+ orientation: body_transform.rotation.to_a
761
+ )
762
+ end
763
+
764
+ REXML::XPath.match(body_element, "body").each do |child|
765
+ body_ids.concat(load_mjcf_body(child, body_transform, global_scaling, base_path))
766
+ end
767
+ body_ids
768
+ end
769
+
770
+ def mjcf_body_transform(body_element, global_scaling)
771
+ position = float_list(body_element.attributes["pos"] || "0 0 0").map { |value| value * global_scaling }
772
+ orientation = body_element.attributes["quat"] ? Quaternion.coerce(float_list(body_element.attributes["quat"])) : Quaternion.identity
773
+ Transform.new(orientation, Vector3.coerce(position))
774
+ end
775
+
776
+ def mjcf_body_mass(body_element, geoms)
777
+ Float(body_element.attributes["mass"] || geoms.lazy.map { |geom| geom.attributes["mass"] }.find(&:itself) || 1.0)
778
+ end
779
+
780
+ def mjcf_geoms_shape(geoms, global_scaling, base_path)
781
+ shapes = geoms.map do |geom|
782
+ [mjcf_geom_shape(geom, global_scaling, base_path), mjcf_geom_transform(geom, global_scaling)]
783
+ end
784
+ return shapes.first.first if shapes.length == 1 && identity_transform?(shapes.first.last)
785
+
786
+ compound = Shapes::CompoundShape.new
787
+ shapes.each { |shape, transform| compound.add_child_shape(transform, shape) }
788
+ compound
789
+ end
790
+
791
+ def mjcf_geom_shape(geom, global_scaling, base_path)
792
+ type = geom.attributes["type"] || "sphere"
793
+ size = float_list(geom.attributes["size"] || "1")
794
+ case type
795
+ when "box"
796
+ Shapes::BoxShape.new(Vector3.coerce(size.first(3).map { |value| value * global_scaling }))
797
+ when "sphere"
798
+ Shapes::SphereShape.new(Float(size.fetch(0)) * global_scaling)
799
+ when "capsule"
800
+ Shapes::CapsuleShape.new(Float(size.fetch(0)) * global_scaling, Float(size.fetch(1, size.fetch(0))) * global_scaling * 2.0)
801
+ when "cylinder"
802
+ radius = Float(size.fetch(0)) * global_scaling
803
+ half_height = Float(size.fetch(1, size.fetch(0))) * global_scaling
804
+ Shapes::CylinderShape.new(Vector3.new(radius, half_height, radius))
805
+ when "mesh"
806
+ filename = geom.attributes["file"] || geom.attributes["mesh"]
807
+ Shapes::TriangleMeshShape.new(load_obj_triangles(resolve_mesh_filename(filename, base_path), [global_scaling, global_scaling, global_scaling]))
808
+ else
809
+ raise ArgumentError, "unsupported MJCF geom type: #{type}"
810
+ end
811
+ end
812
+
813
+ def mjcf_geom_transform(geom, global_scaling)
814
+ position = float_list(geom.attributes["pos"] || "0 0 0").map { |value| value * global_scaling }
815
+ orientation = geom.attributes["quat"] ? Quaternion.coerce(float_list(geom.attributes["quat"])) : Quaternion.identity
816
+ Transform.new(orientation, Vector3.coerce(position))
817
+ end
818
+
819
+ def camera_basis(eye, target, up, fov, far)
820
+ eye = vector_array(eye)
821
+ target = vector_array(target)
822
+ forward = normalize_vector(vector_subtract(target, eye))
823
+ right = normalize_vector(cross_vector(forward, vector_array(up)))
824
+ camera_up = normalize_vector(cross_vector(right, forward))
825
+ {
826
+ eye: eye,
827
+ forward: forward,
828
+ right: right,
829
+ up: camera_up,
830
+ scale: Math.tan(fov * Math::PI / 360.0),
831
+ far: far
832
+ }
833
+ end
834
+
835
+ def camera_ray_hit(camera, column, row, width, height)
836
+ aspect = width.to_f / height
837
+ x = ((column + 0.5) / width * 2.0 - 1.0) * aspect * camera.fetch(:scale)
838
+ y = (1.0 - (row + 0.5) / height * 2.0) * camera.fetch(:scale)
839
+ direction = normalize_vector(vector_add(camera.fetch(:forward), vector_add(vector_scale_array(camera.fetch(:right), x), vector_scale_array(camera.fetch(:up), y))))
840
+ ray_to = vector_add(camera.fetch(:eye), vector_scale_array(direction, camera.fetch(:far)))
841
+ ray_test(camera.fetch(:eye), ray_to)
842
+ end
843
+
844
+ def append_camera_pixel(hit, rgb, depth, segmentation, background_color, body_colors)
845
+ if hit
846
+ body_id = body_id_for(hit[:body]) || -1
847
+ rgb.concat(body_colors.fetch(body_id) { body_color(body_id) })
848
+ depth << hit[:fraction]
849
+ segmentation << body_id
850
+ else
851
+ rgb.concat(background_color)
852
+ depth << 1.0
853
+ segmentation << -1
854
+ end
855
+ end
856
+
857
+ def body_color(body_id)
858
+ return [220, 220, 220, 255] if body_id.negative?
859
+
860
+ @body_specs.fetch(body_id, nil)&.fetch(:rgba_color, nil) || [220, 220, 220, 255]
861
+ end
862
+
863
+ def draw_world_fallback(drawer)
864
+ debug_mode = drawer.respond_to?(:debug_mode) ? drawer.debug_mode : DebugDraw::DRAW_AABB
865
+ draw_aabbs(drawer) unless (debug_mode & DebugDraw::DRAW_AABB).zero?
866
+ draw_contacts(drawer) unless (debug_mode & DebugDraw::DRAW_CONTACT_POINTS).zero?
867
+ drawer
868
+ end
869
+
870
+ def draw_aabbs(drawer)
871
+ @bodies.each_index do |body_id|
872
+ next unless @bodies[body_id]
873
+
874
+ draw_aabb(drawer, get_aabb(body_id), [1.0, 0.0, 0.0])
875
+ end
876
+ end
877
+
878
+ def draw_aabb(drawer, aabb, color)
879
+ min, max = aabb
880
+ corners = [
881
+ [min[0], min[1], min[2]], [max[0], min[1], min[2]],
882
+ [max[0], max[1], min[2]], [min[0], max[1], min[2]],
883
+ [min[0], min[1], max[2]], [max[0], min[1], max[2]],
884
+ [max[0], max[1], max[2]], [min[0], max[1], max[2]]
885
+ ]
886
+ [[0, 1], [1, 2], [2, 3], [3, 0], [4, 5], [5, 6], [6, 7], [7, 4], [0, 4], [1, 5], [2, 6], [3, 7]].each do |from, to|
887
+ drawer.draw_line(corners[from], corners[to], color)
888
+ end
889
+ end
890
+
891
+ def draw_contacts(drawer)
892
+ get_contact_points.each do |contact|
893
+ drawer.draw_contact_point(
894
+ contact.fetch(:position_world_on_b),
895
+ contact.fetch(:normal_world_on_b),
896
+ distance: contact.fetch(:distance),
897
+ life_time: 0,
898
+ color: [1.0, 1.0, 0.0]
899
+ )
900
+ end
901
+ end
902
+
903
+ def clear_importers
904
+ @importers.each(&:delete_all_data)
905
+ @importers.clear
906
+ nil
907
+ end
908
+
909
+ def world_snapshot
910
+ {
911
+ shapes: @shape_specs.map { |spec| spec || raise(ArgumentError, "world contains an unserializable shape") },
912
+ bodies: @body_specs.each_with_index.map { |spec, index| spec && body_snapshot(index, spec) },
913
+ constraints: @constraint_specs
914
+ }
915
+ end
916
+
917
+ def body_snapshot(index, spec)
918
+ transform = body_for(index).world_transform
919
+ spec.merge(position: transform.origin.to_a, orientation: transform.rotation.to_a)
920
+ end
921
+
922
+ def serializable_options(value)
923
+ case value
924
+ when Hash
925
+ value.to_h { |key, child| [key, serializable_options(child)] }
926
+ when Array
927
+ value.map { |child| serializable_options(child) }
928
+ when Transform
929
+ { position: value.origin.to_a, orientation: value.rotation.to_a }
930
+ when Vector3, Quaternion
931
+ value.to_a
932
+ when Symbol
933
+ value.to_s
934
+ else
935
+ value
936
+ end
937
+ end
938
+
939
+ def symbolize_hash(value)
940
+ case value
941
+ when Hash
942
+ value.to_h { |key, child| [key.to_sym, symbolize_hash(child)] }
943
+ when Array
944
+ value.map { |child| symbolize_hash(child) }
945
+ else
946
+ value
947
+ end
948
+ end
949
+
950
+ def vector_array(value)
951
+ Vector3.coerce(value).to_a
952
+ end
953
+
954
+ def vector_add(left, right)
955
+ left.zip(right).map { |a, b| a + b }
956
+ end
957
+
958
+ def vector_subtract(left, right)
959
+ left.zip(right).map { |a, b| a - b }
960
+ end
961
+
962
+ def vector_scale_array(vector, scalar)
963
+ vector.map { |component| component * scalar }
964
+ end
965
+
966
+ def cross_vector(left, right)
967
+ [
968
+ left[1] * right[2] - left[2] * right[1],
969
+ left[2] * right[0] - left[0] * right[2],
970
+ left[0] * right[1] - left[1] * right[0]
971
+ ]
972
+ end
973
+
974
+ def normalize_vector(vector)
975
+ length = Math.sqrt(vector.sum { |component| component * component })
976
+ raise ArgumentError, "cannot normalize zero-length vector" if length.zero?
977
+
978
+ vector.map { |component| component / length }
979
+ end
980
+
981
+ def manifold_contacts
982
+ world.contact_manifolds.flat_map do |manifold|
983
+ manifold[:points].map do |point|
984
+ point.merge(body0: manifold[:body0], body1: manifold[:body1])
985
+ end
986
+ end
987
+ end
988
+
989
+ def contact_with_body_ids(contact)
990
+ contact.merge(
991
+ body0: body_id_for(contact[:body0]),
992
+ body1: body_id_for(contact[:body1])
993
+ )
994
+ end
995
+
996
+ def urdf_link_mass(link)
997
+ mass_element = REXML::XPath.first(link, "inertial/mass")
998
+ return 0.0 unless mass_element
999
+
1000
+ Float(mass_element.attributes["value"])
1001
+ end
1002
+
1003
+ def urdf_shape_spec(link, global_scaling, base_path)
1004
+ collisions = REXML::XPath.match(link, "collision")
1005
+ return nil unless collisions.length == 1
1006
+
1007
+ collision = collisions.first
1008
+ return nil unless identity_transform?(urdf_origin_transform(REXML::XPath.first(collision, "origin"), global_scaling))
1009
+
1010
+ urdf_geometry_shape_spec(REXML::XPath.first(collision, "geometry"), global_scaling, base_path)
1011
+ end
1012
+
1013
+ def urdf_geometry_shape_spec(geometry, global_scaling, base_path)
1014
+ if (box = REXML::XPath.first(geometry, "box"))
1015
+ return { type: :box, options: { half_extents: float_list(box.attributes["size"]).map { |value| value * global_scaling * 0.5 } } }
1016
+ end
1017
+ if (sphere = REXML::XPath.first(geometry, "sphere"))
1018
+ return { type: :sphere, options: { radius: Float(sphere.attributes["radius"]) * global_scaling } }
1019
+ end
1020
+ if (cylinder = REXML::XPath.first(geometry, "cylinder"))
1021
+ radius = Float(cylinder.attributes["radius"]) * global_scaling
1022
+ length = Float(cylinder.attributes["length"]) * global_scaling
1023
+ return { type: :cylinder, options: { half_extents: [radius, length * 0.5, radius] } }
1024
+ end
1025
+ if (capsule = REXML::XPath.first(geometry, "capsule"))
1026
+ return { type: :capsule, options: { radius: Float(capsule.attributes["radius"]) * global_scaling, height: Float(capsule.attributes["length"]) * global_scaling } }
1027
+ end
1028
+ if (mesh = REXML::XPath.first(geometry, "mesh"))
1029
+ return { type: :mesh, options: { filename: resolve_mesh_filename(mesh.attributes["filename"], base_path), scale: vector_scale(mesh.attributes["scale"], global_scaling) } }
1030
+ end
1031
+
1032
+ nil
1033
+ end
1034
+
1035
+ def urdf_collision_shape(link, global_scaling, base_path)
1036
+ collisions = REXML::XPath.match(link, "collision")
1037
+ raise ArgumentError, "URDF link has no collision geometry" if collisions.empty?
1038
+
1039
+ shapes_with_origins = collisions.map do |collision|
1040
+ geometry = REXML::XPath.first(collision, "geometry")
1041
+ raise ArgumentError, "URDF collision has no geometry" unless geometry
1042
+
1043
+ [urdf_geometry_shape(geometry, global_scaling, base_path), urdf_origin_transform(REXML::XPath.first(collision, "origin"), global_scaling)]
1044
+ end
1045
+
1046
+ return shapes_with_origins.first.first if shapes_with_origins.length == 1 && identity_transform?(shapes_with_origins.first.last)
1047
+
1048
+ compound = Shapes::CompoundShape.new
1049
+ shapes_with_origins.each do |shape, transform|
1050
+ compound.add_child_shape(transform, shape)
1051
+ end
1052
+ compound
1053
+ end
1054
+
1055
+ def urdf_geometry_shape(geometry, global_scaling, base_path)
1056
+ if (box = REXML::XPath.first(geometry, "box"))
1057
+ size = float_list(box.attributes["size"]).map { |value| value * global_scaling * 0.5 }
1058
+ return Shapes::BoxShape.new(Vector3.coerce(size))
1059
+ end
1060
+
1061
+ if (sphere = REXML::XPath.first(geometry, "sphere"))
1062
+ return Shapes::SphereShape.new(Float(sphere.attributes["radius"]) * global_scaling)
1063
+ end
1064
+
1065
+ if (cylinder = REXML::XPath.first(geometry, "cylinder"))
1066
+ radius = Float(cylinder.attributes["radius"]) * global_scaling
1067
+ length = Float(cylinder.attributes["length"]) * global_scaling
1068
+ return Shapes::CylinderShape.new(Vector3.new(radius, length * 0.5, radius))
1069
+ end
1070
+
1071
+ if (capsule = REXML::XPath.first(geometry, "capsule"))
1072
+ return Shapes::CapsuleShape.new(
1073
+ Float(capsule.attributes["radius"]) * global_scaling,
1074
+ Float(capsule.attributes["length"]) * global_scaling
1075
+ )
1076
+ end
1077
+
1078
+ mesh = REXML::XPath.first(geometry, "mesh")
1079
+ if mesh
1080
+ scale = vector_scale(mesh.attributes["scale"], global_scaling)
1081
+ return Shapes::TriangleMeshShape.new(load_obj_triangles(resolve_mesh_filename(mesh.attributes["filename"], base_path), scale))
1082
+ end
1083
+
1084
+ raise ArgumentError, "unsupported URDF collision geometry"
1085
+ end
1086
+
1087
+ def resolve_mesh_filename(filename, base_path)
1088
+ normalized = filename.to_s.sub(%r{\Afile://}, "").sub(%r{\Apackage://}, "")
1089
+ candidate = File.expand_path(normalized, base_path)
1090
+ return candidate if File.exist?(candidate)
1091
+
1092
+ Data.find(normalized)
1093
+ end
1094
+
1095
+ def vector_scale(value, global_scaling)
1096
+ parts = value ? float_list(value) : [1.0, 1.0, 1.0]
1097
+ raise ArgumentError, "URDF mesh scale must have 3 components" unless parts.length == 3
1098
+
1099
+ parts.map { |component| component * global_scaling }
1100
+ end
1101
+
1102
+ def load_obj_triangles(path, scale)
1103
+ vertices = []
1104
+ triangles = []
1105
+
1106
+ File.foreach(path) do |line|
1107
+ parts = line.strip.split
1108
+ next if parts.empty? || parts.first.start_with?("#")
1109
+
1110
+ case parts.first
1111
+ when "v"
1112
+ vertices << scaled_vertex(parts, scale)
1113
+ when "f"
1114
+ indices = face_indices(parts.drop(1), vertices.length)
1115
+ (1...(indices.length - 1)).each do |index|
1116
+ triangles << [vertices[indices.first], vertices[indices[index]], vertices[indices[index + 1]]]
1117
+ end
1118
+ end
1119
+ end
1120
+
1121
+ raise ArgumentError, "OBJ mesh has no triangles: #{path}" if triangles.empty?
1122
+
1123
+ triangles
1124
+ end
1125
+
1126
+ def scaled_vertex(parts, scale)
1127
+ raise ArgumentError, "OBJ vertex must have 3 components" if parts.length < 4
1128
+
1129
+ [
1130
+ Float(parts[1]) * scale[0],
1131
+ Float(parts[2]) * scale[1],
1132
+ Float(parts[3]) * scale[2]
1133
+ ]
1134
+ end
1135
+
1136
+ def face_indices(tokens, vertex_count)
1137
+ indices = tokens.map do |token|
1138
+ index = Integer(token.split("/").first)
1139
+ index.negative? ? vertex_count + index : index - 1
1140
+ end
1141
+ raise ArgumentError, "OBJ face must have at least 3 vertices" if indices.length < 3
1142
+
1143
+ indices
1144
+ end
1145
+
1146
+ def urdf_origin_transform(origin, global_scaling)
1147
+ return Transform.identity unless origin
1148
+
1149
+ xyz = float_list(origin.attributes["xyz"] || "0 0 0").map { |value| value * global_scaling }
1150
+ rpy = float_list(origin.attributes["rpy"] || "0 0 0")
1151
+ Transform.new(Quaternion.from_euler(*rpy), Vector3.coerce(xyz))
1152
+ end
1153
+
1154
+ def identity_transform?(transform)
1155
+ transform.origin == Vector3.zero && transform.rotation == Quaternion.identity
1156
+ end
1157
+
1158
+ def apply_urdf_contact(link, body)
1159
+ lateral_friction = REXML::XPath.first(link, "contact/lateral_friction")
1160
+ body.friction = Float(lateral_friction.attributes["value"]) if lateral_friction
1161
+
1162
+ restitution = REXML::XPath.first(link, "contact/restitution")
1163
+ body.restitution = Float(restitution.attributes["value"]) if restitution
1164
+ end
1165
+
1166
+ def float_list(value)
1167
+ value.to_s.split.map { |part| Float(part) }
1168
+ end
1169
+ end
1170
+ end