CARLA
Loading...
Searching...
No Matches
test_geom.cpp
Go to the documentation of this file.
1// Copyright (c) 2026 Computer Vision Center (CVC) at the Universitat Autonoma
2// de Barcelona (UAB).
3//
4// This work is licensed under the terms of the MIT license.
5// For a copy, see <https://opensource.org/licenses/MIT>.
6
7#include "test.h"
8
12#include <carla/geom/Math.h>
19#include <carla/geom/Vector3D.h>
20#include <carla/geom/Velocity.h>
21#include <array>
22#include <cmath>
23#include <limits>
24
25namespace carla {
26namespace geom {
27
28 std::ostream &operator<<(std::ostream &out, const Vector3D &vector3D) {
29 out << "{x=" << vector3D.x << ", y=" << vector3D.y << ", z=" << vector3D.z << '}';
30 return out;
31 }
32
33} // namespace geom
34} // namespace carla
35
36using namespace carla::geom;
37
38TEST(geom, single_point_no_transform) {
39 constexpr double error = 0.001;
40
41 Location translation (0.0, 0.0, 0.0);
42 Rotation rotation(0.0, 0.0, 0.0);
43 Transform transform (translation, rotation);
44
45 Location point (1.0,1.0,1.0);
46 transform.TransformPoint(point);
47 Location result_point(1.0, 1.0, 1.0);
48
49 ASSERT_NEAR(point.x, result_point.x, error);
50 ASSERT_NEAR(point.y, result_point.y, error);
51 ASSERT_NEAR(point.z, result_point.z, error);
52
53}
54
55TEST(geom, single_point_translation) {
56 constexpr double error = 0.001;
57
58 Location translation (2.0,5.0,7.0);
59 Rotation rotation (0.0, 0.0, 0.0);
60 Transform transform (translation, rotation);
61
62 Location point (0.0, 0.0, 0.0);
63 transform.TransformPoint(point);
64 Location result_point(2.0, 5.0, 7.0);
65
66 ASSERT_NEAR(point.x, result_point.x, error);
67 ASSERT_NEAR(point.y, result_point.y, error);
68 ASSERT_NEAR(point.z, result_point.z, error);
69}
70
71
72TEST(geom, single_point_transform_inverse_transform_coherence) {
73 constexpr double error = 0.001;
74
75 const Location point(-3.14f, 1.337f, 4.20f);
76 const Location translation (1.41f, -4.7f, 9.2f);
77 const Rotation rotation (-47.0f, 37.0f, 250.2f);
78 const Transform transform (translation, rotation);
79
80 auto transformed_point = point;
81 transform.TransformPoint(transformed_point);
82
83 auto point_back_to_normal = transformed_point;
84 transform.InverseTransformPoint(point_back_to_normal);
85
86 ASSERT_NEAR(point.x, point_back_to_normal.x, error) << "result.x is " << point_back_to_normal.x << " but expected " << point.x;
87 ASSERT_NEAR(point.y, point_back_to_normal.y, error) << "result.y is " << point_back_to_normal.y << " but expected " << point.y;
88 ASSERT_NEAR(point.z, point_back_to_normal.z, error) << "result.z is " << point_back_to_normal.z << " but expected " << point.z;
89}
90
91
92TEST(geom, bbox_get_local_vertices_get_world_vertices_coherence) {
93 constexpr double error = 0.001;
94
95 const BoundingBox bbox (Location(10.2f, -32.4f, 15.6f), Vector3D(9.2f, 13.5f, 20.3f));
96
97 const Location bbox_location(-3.14f, 1.337f, 4.20f);
98 const Rotation bbox_rotation (-59.0f, 17.0f, -650.2f);
99 const Transform bbox_transform(bbox_location, bbox_rotation);
100
101 const auto local_vertices = bbox.GetLocalVertices();
102 const auto world_vertices = bbox.GetWorldVertices(bbox_transform);
103 for (auto i = 0u; i < local_vertices.size(); ++i){
104 const auto &local_vertex = local_vertices[i];
105
106 auto transformed_local_vertex = local_vertex;
107 bbox_transform.TransformPoint(transformed_local_vertex);
108
109 const auto &world_vertex = world_vertices[i];
110
111 ASSERT_NEAR(transformed_local_vertex.x, world_vertex.x, error) << "result.x is " << transformed_local_vertex.x << " but expected " << world_vertex.x;
112 ASSERT_NEAR(transformed_local_vertex.y, world_vertex.y, error) << "result.y is " << transformed_local_vertex.y << " but expected " << world_vertex.y;
113 ASSERT_NEAR(transformed_local_vertex.z, world_vertex.z, error) << "result.z is " << transformed_local_vertex.z << " but expected " << world_vertex.z;
114 }
115}
116
117
118TEST(geom, single_point_rotation) {
119 constexpr double error = 0.001;
120
121 Location translation (0.0,0.0,0.0);
122 Rotation rotation (0.0,180.0,0.0); // y z x
123 Transform transform (translation, rotation);
124
125 Location point (0.0, 0.0, 1.0);
126 transform.TransformPoint(point);
127 Location result_point(0.0, 0.0, 1.0);
128 ASSERT_NEAR(point.x, result_point.x, error);
129 ASSERT_NEAR(point.y, result_point.y, error);
130 ASSERT_NEAR(point.z, result_point.z, error);
131}
132
133TEST(geom, single_point_translation_and_rotation) {
134 constexpr double error = 0.001;
135
136 // Engine convention: `+pitch` tilts the forward axis up, so pitch=90
137 // sends the up axis {0, 0, 1} onto the backward axis {-1, 0, 0}. The
138 // point {0, 0, 2} therefore lands at {-2, 0, 0}, and the translation by
139 // {0, 0, -1} brings the final point to {-2, 0, -1}.
140 // #9751 mirrored this to {2, 0, -1}; the engine disagrees (a camera at
141 // pitch=+20 looks at the sky, verified on UE5.8 2026-08-27).
142 Location translation (0.0,0.0,-1.0); // x y z
143 Rotation rotation (90.0,0.0,0.0); // y z x
144 Transform transform (translation, rotation);
145
146 Location point (0.0, 0.0, 2.0);
147 transform.TransformPoint(point);
148 Location result_point(-2.0, 0.0, -1.0);
149 ASSERT_NEAR(point.x, result_point.x, error);
150 ASSERT_NEAR(point.y, result_point.y, error);
151 ASSERT_NEAR(point.z, result_point.z, error);
152}
153
155 constexpr double error = .01;
156 ASSERT_NEAR(Math::Distance({0, 0, 0}, {0, 0, 0}), 0.0, error);
157 ASSERT_NEAR(Math::Distance({1, 1, 1}, {0, 0, 0}), 1.732051, error);
158 ASSERT_NEAR(Math::Distance({0, 0, 0}, {1, 1, 1}), 1.732051, error);
159 ASSERT_NEAR(Math::Distance({-1, -1, -1}, {0, 0, 0}), 1.732051, error);
160 ASSERT_NEAR(Math::Distance({0, 0, 0}, {-1, -1, -1}), 1.732051, error);
161 ASSERT_NEAR(Math::Distance({7, 4, 3}, {17, 6, 2}), 10.246951, error);
162 ASSERT_NEAR(Math::Distance({7, -4, 3}, {-17, 6, 2}), 26.019224, error);
163 ASSERT_NEAR(Math::Distance({5, 6, 7}, {-6, 3, -4}), 15.84298, error);
164 ASSERT_NEAR(Math::Distance({7, 4, 3}, {17, 6, 2}), 10.246951, error);
165}
166
167TEST(geom, nearest_point_segment) {
168 const float segment[] = {
169 0, 0, 10, 0,
170 2, 5, 10, 8,
171 -6, 8, 8, -2,
172 8, 2,-10, 3,
173 3, 3, -6, -5,
174 3, -3, 2, 5,
175 4, -6, 5, 4,
176 -1, -4,-10, 8,
177 -7, -5, 5, 5,
178 -5, 6, 3, -9
179 };
180
181 const Vector3D point[] = {
182 { 1, -1, 0},
183 { 10, 10, 0},
184 {-10, 10, 0},
185 { 10, -10, 0},
186 {-10, -10, 0},
187 { 0, 5, 0},
188 { 0, -5, 0},
189 { 1, 4, 0},
190 { -1, 1, 0},
191 { 3, 2.5, 0}
192 };
193
194 const int results[] = {
195 0, 1, 7, 9, 8, 2, 9, 2, 8, 3
196 };
197
198 for (int i = 0; i < 10; ++i) {
199 double min_dist = std::numeric_limits<double>::max();
200 int id = -1;
201 for (int j = 0; j < 40; j += 4) {
202 const double dist = Math::DistanceSegmentToPoint(
203 point[i],
204 {segment[j + 0], segment[j + 1], 0},
205 {segment[j + 2], segment[j + 3], 0}).second;
206 if (dist < min_dist) {
207 min_dist = dist;
208 id = j / 4;
209 }
210 }
211 ASSERT_EQ(id, results[i]) << "Fails point number: " << i;
212 }
213}
214
215TEST(geom, forward_vector) {
216 auto compare = [](Rotation rotation, Vector3D expected) {
217 constexpr float eps = 2.0f * std::numeric_limits<float>::epsilon();
218 auto result = rotation.GetForwardVector();
219 EXPECT_TRUE(
220 (std::abs(expected.x - result.x) < eps) &&
221 (std::abs(expected.y - result.y) < eps) &&
222 (std::abs(expected.z - result.z) < eps))
223 << "result = " << result << '\n'
224 << "expected = " << expected;
225 };
226 // pitch yaw roll x y z
227 compare({ 0.0f, 0.0f, 0.0f}, {1.0f, 0.0f, 0.0f});
228 compare({ 0.0f, 0.0f, 123.0f}, {1.0f, 0.0f, 0.0f});
229 compare({360.0f, 360.0f, 0.0f}, {1.0f, 0.0f, 0.0f});
230 compare({ 0.0f, 90.0f, 0.0f}, {0.0f, 1.0f, 0.0f});
231 compare({ 0.0f, -90.0f, 0.0f}, {0.0f,-1.0f, 0.0f});
232 // Engine convention: `+pitch` is nose up, so pitch=90 points forward
233 // straight up, {0, 0, +1}. Empirically confirmed on the running UE5.8
234 // server (2026-08-27): a camera at Rotation(pitch=+20) fills the frame
235 // with sky. #9751 briefly asserted {0, 0, -1} here.
236 compare({ 90.0f, 0.0f, 0.0f}, {0.0f, 0.0f, 1.0f});
237 compare({180.0f, -90.0f, 0.0f}, {0.0f, 1.0f, 0.0f});
238}
239
240TEST(geom, nearest_point_arc) {
241 ASSERT_NEAR(Math::DistanceArcToPoint(Vector3D(1,0,0),
242 Vector3D(0,0,0), 1.57f, 0, 1).second, 0.414214f, 0.01f);
243 ASSERT_NEAR(Math::DistanceArcToPoint(Vector3D(2,1,0),
244 Vector3D(0,0,0), 1.57f, 0, 1).second, 1.0f, 0.01f);
245 ASSERT_NEAR(Math::DistanceArcToPoint(Vector3D(0,1,0),
246 Vector3D(0,0,0), 1.57f, 0, 1).second, 1.0f, 0.01f);
247 ASSERT_NEAR(Math::DistanceArcToPoint(Vector3D(1,2,0),
248 Vector3D(0,0,0), 1.57f, 0, 1).second, 1.0f, 0.01f);
249}
250
251TEST(geom, right_handed_vector3d_boundary_negation) {
252 // Going Vector3D -> RightHandedVector3D negates Y exactly once.
253 const Vector3D lh{1.0f, 2.0f, 3.0f};
254 const RightHandedVector3D rh{lh};
255 ASSERT_FLOAT_EQ(rh.x, 1.0f);
256 ASSERT_FLOAT_EQ(rh.y, -2.0f);
257 ASSERT_FLOAT_EQ(rh.z, 3.0f);
258
259 // Round-trip back to Vector3D restores the original.
260 const Vector3D round_trip = static_cast<Vector3D>(rh);
261 ASSERT_FLOAT_EQ(round_trip.x, 1.0f);
262 ASSERT_FLOAT_EQ(round_trip.y, 2.0f);
263 ASSERT_FLOAT_EQ(round_trip.z, 3.0f);
264}
265
266TEST(geom, quaternion_default_is_identity) {
267 const Quaternion q;
268 ASSERT_FLOAT_EQ(q.x, 0.0f);
269 ASSERT_FLOAT_EQ(q.y, 0.0f);
270 ASSERT_FLOAT_EQ(q.z, 0.0f);
271 ASSERT_FLOAT_EQ(q.w, 1.0f);
272
273 // Identity quaternion rotates a vector to itself.
274 const Vector3D v{1.0f, 2.0f, 3.0f};
275 const Vector3D out = static_cast<Vector3D>(q.RotatedVector(RightHandedVector3D{v}));
276 ASSERT_NEAR(out.x, v.x, 1e-5f);
277 ASSERT_NEAR(out.y, v.y, 1e-5f);
278 ASSERT_NEAR(out.z, v.z, 1e-5f);
279}
280
281TEST(geom, quaternion_get_forward_matches_rotation_get_forward) {
282 // The quaternion built from a Rotation must agree with that Rotation's
283 // forward-vector for every axis-aligned angle. This is the canonical
284 // regression guard against drift between the two math paths.
285 constexpr float eps = 1e-4f;
286 const float angles[] = {-180.0f, -90.0f, -45.0f, 0.0f, 30.0f, 90.0f, 123.0f, 180.0f};
287 for (const float a : angles) {
288 for (const auto axis : {0, 1, 2}) {
289 Rotation r{};
290 if (axis == 0) r.pitch = a;
291 else if (axis == 1) r.yaw = a;
292 else r.roll = a;
293
294 const Vector3D r_forward = r.GetForwardVector();
295 const Vector3D q_forward = Quaternion(r).GetForwardVector();
296 EXPECT_NEAR(q_forward.x, r_forward.x, eps) << "axis=" << axis << " angle=" << a;
297 EXPECT_NEAR(q_forward.y, r_forward.y, eps) << "axis=" << axis << " angle=" << a;
298 EXPECT_NEAR(q_forward.z, r_forward.z, eps) << "axis=" << axis << " angle=" << a;
299 }
300 }
301}
302
303TEST(geom, quaternion_get_yaw_round_trip) {
304 // Yaw component of Rotation -> Quaternion -> Rotation must round-trip.
305 // Pitch and roll left at zero so the Rotator extraction is unambiguous.
306 constexpr float eps = 1e-3f;
307 for (float yaw_deg = -150.0f; yaw_deg <= 150.0f; yaw_deg += 30.0f) {
308 const Quaternion q{Rotation{0.0f, yaw_deg, 0.0f}};
309 const Rotation r = q.Rotator();
310 EXPECT_NEAR(r.yaw, yaw_deg, eps) << "yaw=" << yaw_deg;
311 EXPECT_NEAR(r.pitch, 0.0f, eps);
312 EXPECT_NEAR(r.roll, 0.0f, eps);
313 }
314}
315
316TEST(geom, quaternion_inverse_is_left_inverse) {
317 // q * q^-1 must be the identity for sampled axis-aligned and combined
318 // rotations. Tolerances are generous to accommodate float accumulation.
319 constexpr float eps = 1e-4f;
320 const Rotation samples[] = {
321 {0.0f, 0.0f, 0.0f},
322 {30.0f, 0.0f, 0.0f},
323 {0.0f, 45.0f, 0.0f},
324 {0.0f, 0.0f, 60.0f},
325 {15.0f, 25.0f, 10.0f},
326 {-45.0f, 90.0f, -30.0f},
327 };
328 for (const auto &r : samples) {
329 const Quaternion q{r};
330 const Quaternion identity = q * q.Inverse();
331 EXPECT_NEAR(identity.x, 0.0f, eps) << r.pitch << "/" << r.yaw << "/" << r.roll;
332 EXPECT_NEAR(identity.y, 0.0f, eps);
333 EXPECT_NEAR(identity.z, 0.0f, eps);
334 EXPECT_NEAR(std::abs(identity.w), 1.0f, eps);
335 }
336}
337
338TEST(geom, quaternion_pitch_90_forward_is_positive_z) {
339 // Engine convention guard: `+pitch` is nose up, so the forward vector of
340 // pitch=90 is {0, 0, +1}, the same answer `Rotation::GetForwardVector`
341 // gives. #9751 asserted {0, 0, -1} here, which mirrored the engine.
342 const Quaternion q{Rotation{90.0f, 0.0f, 0.0f}};
343 const Vector3D forward = q.GetForwardVector();
344 EXPECT_NEAR(forward.x, 0.0f, 1e-5f);
345 EXPECT_NEAR(forward.y, 0.0f, 1e-5f);
346 EXPECT_NEAR(forward.z, 1.0f, 1e-5f);
347}
348
349TEST(geom, engine_convention_pitch_up_roll_right_side_down) {
350 // The two empirical facts this whole sign convention rests on, measured
351 // on the running UE5.8 server on 2026-08-27 with a camera sensor and
352 // rendered frames (scratchpad pose_check.py, pitch_+-20.png /
353 // roll_+-25.png):
354 // * a camera at Rotation(pitch=+20) is tilted UP -> forward.z > 0
355 // * a camera at Rotation(roll=+25) has its right side DOWN -> right.z < 0
356 // These match CARLA 0.9.x and the documented API semantics. #9751
357 // mirrored both.
358 constexpr float eps = 1e-5f;
359
360 const Rotation pitched{20.0f, 0.0f, 0.0f};
361 const Vector3D forward = pitched.GetForwardVector();
362 EXPECT_GT(forward.z, 0.0f);
363 EXPECT_NEAR(forward.z, std::sin(Math::ToRadians(20.0f)), eps);
364
365 const Rotation rolled{0.0f, 0.0f, 25.0f};
366 const Vector3D right = rolled.GetRightVector();
367 EXPECT_LT(right.z, 0.0f);
368 EXPECT_NEAR(right.z, -std::sin(Math::ToRadians(25.0f)), eps);
369
370 // Up completes a right-handed... (left-handed, in CARLA) orthonormal
371 // basis: for pure roll the up axis leans toward +Y.
372 const Vector3D up = rolled.GetUpVector();
373 EXPECT_GT(up.y, 0.0f);
374 EXPECT_NEAR(up.y, std::sin(Math::ToRadians(25.0f)), eps);
375}
376
377TEST(geom, get_matrix_agrees_with_rotate_vector_and_basis_vectors) {
378 // All four public math paths (Transform::GetMatrix, Rotation::RotateVector,
379 // Math::Get{Forward,Right,Up}Vector and Quaternion(Rotation)) must produce
380 // the same rotation for arbitrary pitch/yaw/roll. This is the guard that
381 // keeps a future sign edit from touching one path only.
382 constexpr float eps = 1e-4f;
383 const Rotation samples[] = {
384 {0.0f, 0.0f, 0.0f},
385 {20.0f, 0.0f, 0.0f},
386 {-20.0f, 0.0f, 0.0f},
387 {0.0f, 0.0f, 25.0f},
388 {0.0f, 0.0f, -25.0f},
389 {13.0f, 47.0f, -31.0f},
390 {-62.0f, -155.0f, 88.0f},
391 };
392 const Vector3D axes[] = {
393 {1.0f, 0.0f, 0.0f}, {0.0f, 1.0f, 0.0f}, {0.0f, 0.0f, 1.0f}};
394
395 for (const auto &r : samples) {
396 const Transform t{Location{}, r};
397 const std::array<float, 16> m = t.GetMatrix();
398 const Vector3D basis[] = {
399 r.GetForwardVector(), r.GetRightVector(), r.GetUpVector()};
400 const Vector3D q_basis[] = {
404
405 for (int col = 0; col < 3; ++col) {
406 const Vector3D rotated = r.RotateVector(axes[col]);
407 // Column `col` of the 4x4 row-major matrix.
408 const float mx = m[static_cast<size_t>(col)];
409 const float my = m[static_cast<size_t>(4 + col)];
410 const float mz = m[static_cast<size_t>(8 + col)];
411 EXPECT_NEAR(mx, rotated.x, eps) << "col=" << col;
412 EXPECT_NEAR(my, rotated.y, eps) << "col=" << col;
413 EXPECT_NEAR(mz, rotated.z, eps) << "col=" << col;
414 EXPECT_NEAR(basis[col].x, rotated.x, eps) << "col=" << col;
415 EXPECT_NEAR(basis[col].y, rotated.y, eps) << "col=" << col;
416 EXPECT_NEAR(basis[col].z, rotated.z, eps) << "col=" << col;
417 EXPECT_NEAR(q_basis[col].x, rotated.x, eps) << "col=" << col;
418 EXPECT_NEAR(q_basis[col].y, rotated.y, eps) << "col=" << col;
419 EXPECT_NEAR(q_basis[col].z, rotated.z, eps) << "col=" << col;
420 }
421 }
422}
423
424TEST(geom, get_inverse_matrix_is_inverse_of_get_matrix) {
425 // GetInverseMatrix must actually invert GetMatrix, translation included,
426 // for non-zero pitch and roll.
427 constexpr float eps = 1e-3f;
428 const Transform samples[] = {
429 Transform{Location{1.0f, -2.0f, 3.0f}, Rotation{20.0f, 0.0f, 0.0f}},
430 Transform{Location{-5.0f, 4.0f, 0.5f}, Rotation{0.0f, 0.0f, 25.0f}},
431 Transform{Location{10.0f, 20.0f, 30.0f}, Rotation{13.0f, 47.0f, -31.0f}},
432 };
433 for (const auto &t : samples) {
434 const std::array<float, 16> m = t.GetMatrix();
435 const std::array<float, 16> mi = t.GetInverseMatrix();
436 for (int row = 0; row < 4; ++row) {
437 for (int col = 0; col < 4; ++col) {
438 float acc = 0.0f;
439 for (int k = 0; k < 4; ++k) {
440 acc += mi[static_cast<size_t>(row * 4 + k)] *
441 m[static_cast<size_t>(k * 4 + col)];
442 }
443 EXPECT_NEAR(acc, (row == col) ? 1.0f : 0.0f, eps)
444 << "row=" << row << " col=" << col;
445 }
446 }
447 }
448}
449
450TEST(geom, inverse_rotate_vector_undoes_rotate_vector) {
451 constexpr float eps = 1e-4f;
452 const Rotation samples[] = {
453 {20.0f, 0.0f, 0.0f}, {0.0f, 0.0f, 25.0f}, {13.0f, 47.0f, -31.0f}};
454 for (const auto &r : samples) {
455 const Vector3D v{1.0f, -2.0f, 3.0f};
456 Vector3D round_trip = v;
457 r.RotateVector(round_trip);
458 r.InverseRotateVector(round_trip);
459 EXPECT_NEAR(round_trip.x, v.x, eps);
460 EXPECT_NEAR(round_trip.y, v.y, eps);
461 EXPECT_NEAR(round_trip.z, v.z, eps);
462 }
463}
464
465TEST(geom, velocity_inherits_vector3d_storage) {
466 // Velocity is a Vector3D-inheriting POD that carries m/s unit semantics
467 // without adding any data members. Wire-compatibility with Vector3D is
468 // preserved (msgpack adapter inherited).
469 const Velocity v{1.5f, -2.0f, 0.25f};
470 EXPECT_FLOAT_EQ(v.x, 1.5f);
471 EXPECT_FLOAT_EQ(v.y, -2.0f);
472 EXPECT_FLOAT_EQ(v.z, 0.25f);
473
474 // Arithmetic operations inherit from Vector3D.
475 const Velocity v2 = Velocity{v + Vector3D{0.5f, 1.0f, 0.0f}};
476 EXPECT_FLOAT_EQ(v2.x, 2.0f);
477 EXPECT_FLOAT_EQ(v2.y, -1.0f);
478 EXPECT_FLOAT_EQ(v2.z, 0.25f);
479}
480
481TEST(geom, angular_velocity_and_acceleration_inherit_vector3d_storage) {
482 const AngularVelocity w{45.0f, 0.0f, 0.0f};
483 EXPECT_FLOAT_EQ(w.x, 45.0f);
484 EXPECT_FLOAT_EQ(w.y, 0.0f);
485
486 const Acceleration a{0.0f, 9.81f, 0.0f};
487 EXPECT_FLOAT_EQ(a.y, 9.81f);
488
489 // The new types are layout-compatible with Vector3D so the
490 // serialization wire format stays unchanged.
491 static_assert(sizeof(Velocity) == sizeof(Vector3D),
492 "Velocity must stay layout-compatible with Vector3D");
493 static_assert(sizeof(AngularVelocity) == sizeof(Vector3D),
494 "AngularVelocity must stay layout-compatible with Vector3D");
495 static_assert(sizeof(Acceleration) == sizeof(Vector3D),
496 "Acceleration must stay layout-compatible with Vector3D");
497}
498
499TEST(geom, quaternion_basis_vectors_for_identity) {
500 // Identity quaternion's three basis vectors must be the canonical
501 // CARLA left-handed axes.
502 constexpr float eps = 1e-5f;
503 const Quaternion q;
504
505 const Vector3D forward = q.GetForwardVector();
506 EXPECT_NEAR(forward.x, 1.0f, eps);
507 EXPECT_NEAR(forward.y, 0.0f, eps);
508 EXPECT_NEAR(forward.z, 0.0f, eps);
509
510 const Vector3D right = q.GetRightVector();
511 EXPECT_NEAR(right.x, 0.0f, eps);
512 EXPECT_NEAR(right.y, 1.0f, eps);
513 EXPECT_NEAR(right.z, 0.0f, eps);
514
515 const Vector3D up = q.GetUpVector();
516 EXPECT_NEAR(up.x, 0.0f, eps);
517 EXPECT_NEAR(up.y, 0.0f, eps);
518 EXPECT_NEAR(up.z, 1.0f, eps);
519}
520
521TEST(geom, quaternion_basis_vectors_for_yaw_90) {
522 // Yaw=90 deg in CARLA's left-handed convention sends:
523 // forward (+X) -> (+Y), right (+Y) -> (-X), up (+Z) -> (+Z).
524 constexpr float eps = 1e-4f;
525 const Quaternion q{Rotation{0.0f, 90.0f, 0.0f}};
526
527 const Vector3D forward = q.GetForwardVector();
528 EXPECT_NEAR(forward.x, 0.0f, eps);
529 EXPECT_NEAR(forward.y, 1.0f, eps);
530 EXPECT_NEAR(forward.z, 0.0f, eps);
531
532 const Vector3D right = q.GetRightVector();
533 EXPECT_NEAR(right.x, -1.0f, eps);
534 EXPECT_NEAR(right.y, 0.0f, eps);
535 EXPECT_NEAR(right.z, 0.0f, eps);
536
537 const Vector3D up = q.GetUpVector();
538 EXPECT_NEAR(up.x, 0.0f, eps);
539 EXPECT_NEAR(up.y, 0.0f, eps);
540 EXPECT_NEAR(up.z, 1.0f, eps);
541}
542
543TEST(geom, quaternion_hamilton_product_yaw_compose) {
544 // Two consecutive yaw=45 rotations compose to a yaw=90 rotation:
545 // applying the product to the forward axis sends (1,0,0) -> (0,1,0).
546 constexpr float eps = 1e-4f;
547 const Quaternion q45{Rotation{0.0f, 45.0f, 0.0f}};
548 const Quaternion q90 = q45 * q45;
549
550 const Vector3D forward = q90.GetForwardVector();
551 EXPECT_NEAR(forward.x, 0.0f, eps);
552 EXPECT_NEAR(forward.y, 1.0f, eps);
553 EXPECT_NEAR(forward.z, 0.0f, eps);
554}
555
556TEST(geom, quaternion_conjugate_equals_inverse_for_unit_quaternion) {
557 // For a unit quaternion built from any Rotation, conjugate and inverse
558 // are mathematically identical. Sample a few axis-aligned and combined
559 // rotations.
560 constexpr float eps = 1e-5f;
561 const Rotation samples[] = {
562 {0.0f, 0.0f, 0.0f},
563 {30.0f, 0.0f, 0.0f},
564 {0.0f, 45.0f, 0.0f},
565 {0.0f, 0.0f, 60.0f},
566 {15.0f, 25.0f, 10.0f},
567 };
568 for (const auto &r : samples) {
569 const Quaternion q{r};
570 const Quaternion qc = q.Conjugate();
571 const Quaternion qi = q.Inverse();
572 EXPECT_NEAR(qc.x, qi.x, eps) << r.pitch << "/" << r.yaw << "/" << r.roll;
573 EXPECT_NEAR(qc.y, qi.y, eps);
574 EXPECT_NEAR(qc.z, qi.z, eps);
575 EXPECT_NEAR(qc.w, qi.w, eps);
576 }
577}
578
579// =============================================================================
580// -- Right-handed (ROS / REP-103 FLU) boundary --------------------------------
581// =============================================================================
582//
583// The mapping under test, documented in `Docs/coordinate_conventions.md`:
584//
585// location (x, y, z) -> (x, -y, z)
586// rotation (roll,pitch,yaw) -> (roll, -pitch, -yaw), intrinsic Z-Y-X,
587// right-hand rule
588// matrix M -> S * M * S, S = diag(1, -1, 1, 1)
589//
590// Two independent implementations of the same mapping already existed before
591// these adapters and are used here as cross-checks: the ROS 2 bridge's
592// `carla::ros2::TransformFromCarlaRotation` (header-only, no FastDDS deps) and
593// the Cosmos client's `carla_cosmos/coords.py`.
594
595namespace {
596
597 /// `S * m * S` with `S = diag(1, -1, 1, 1)`: negate every entry whose row or
598 /// column -- but not both -- is the mirrored Y index.
599 std::array<float, 16> MirrorY(const std::array<float, 16> &m) {
600 std::array<float, 16> out{};
601 for (int row = 0; row < 4; ++row) {
602 for (int col = 0; col < 4; ++col) {
603 const float sign = ((row == 1) != (col == 1)) ? -1.0f : 1.0f;
604 out[static_cast<size_t>(row * 4 + col)] =
605 sign * m[static_cast<size_t>(row * 4 + col)];
606 }
607 }
608 return out;
609 }
610
611 const Transform kBoundarySamples[] = {
612 Transform{Location{0.0f, 0.0f, 0.0f}, Rotation{0.0f, 0.0f, 0.0f}},
613 Transform{Location{1.0f, 2.0f, 3.0f}, Rotation{20.0f, 30.0f, 10.0f}},
614 Transform{Location{-4.5f, 6.25f, 0.5f}, Rotation{-15.0f, 120.0f, 40.0f}},
615 Transform{Location{100.0f, -50.0f, 2.0f},Rotation{0.0f, -90.0f, 0.0f}},
616 Transform{Location{0.0f, 7.0f, -1.0f}, Rotation{35.0f, 0.0f, 0.0f}},
617 Transform{Location{3.0f, 3.0f, 3.0f}, Rotation{0.0f, 0.0f, -25.0f}},
618 Transform{Location{-9.0f, 0.25f, 11.0f}, Rotation{13.0f, 47.0f, -31.0f}},
619 };
620
621} // namespace
622
623TEST(geom, right_handed_rotation_negates_pitch_and_yaw) {
624 // The reference case: CARLA (roll, pitch, yaw) = (10, 20, 30) is FLU
625 // (10, -20, -30). Note it is *pitch and yaw* that flip, not the "yaw and
626 // roll" claimed by #9751's commit message.
627 const Rotation carla_rotation{20.0f, 30.0f, 10.0f}; // pitch, yaw, roll
628 const RightHandedRotation flu = carla_rotation.ToRightHanded();
629 EXPECT_FLOAT_EQ(flu.roll, 10.0f);
630 EXPECT_FLOAT_EQ(flu.pitch, -20.0f);
631 EXPECT_FLOAT_EQ(flu.yaw, -30.0f);
632
633 // Cross-check against the Cosmos client, which computes the same triple
634 // through matrices: coords.flu_rpy_deg(coords.ue_to_flu(coords.ue_matrix(
635 // carla.Transform(rotation=carla.Rotation(pitch=20, yaw=30, roll=10)))))
636 // == [10.0, -20.0, -30.0].
637
638 const Rotation back = Rotation::FromRightHanded(flu);
639 EXPECT_FLOAT_EQ(back.pitch, carla_rotation.pitch);
640 EXPECT_FLOAT_EQ(back.yaw, carla_rotation.yaw);
641 EXPECT_FLOAT_EQ(back.roll, carla_rotation.roll);
642}
643
644TEST(geom, right_handed_location_mirrors_y) {
645 const Location carla_location{10.0f, 20.0f, 30.0f};
646 const RightHandedVector3D flu = carla_location.ToRightHanded();
647 EXPECT_FLOAT_EQ(flu.x, 10.0f);
648 EXPECT_FLOAT_EQ(flu.y, -20.0f);
649 EXPECT_FLOAT_EQ(flu.z, 30.0f);
650
651 const Location back = Location::FromRightHanded(flu);
652 EXPECT_FLOAT_EQ(back.x, carla_location.x);
653 EXPECT_FLOAT_EQ(back.y, carla_location.y);
654 EXPECT_FLOAT_EQ(back.z, carla_location.z);
655}
656
657TEST(geom, right_handed_transform_round_trips) {
658 constexpr float eps = 1e-4f;
659 for (const auto &t : kBoundarySamples) {
660 const RightHandedTransform flu = t.ToRightHanded();
661 EXPECT_FLOAT_EQ(flu.location.x, t.location.x);
662 EXPECT_FLOAT_EQ(flu.location.y, -t.location.y);
663 EXPECT_FLOAT_EQ(flu.location.z, t.location.z);
664 EXPECT_FLOAT_EQ(flu.rotation.roll, t.rotation.roll);
665 EXPECT_FLOAT_EQ(flu.rotation.pitch, -t.rotation.pitch);
666 EXPECT_FLOAT_EQ(flu.rotation.yaw, -t.rotation.yaw);
667
668 const Transform back = Transform::FromRightHanded(flu);
669 EXPECT_NEAR(back.location.x, t.location.x, eps);
670 EXPECT_NEAR(back.location.y, t.location.y, eps);
671 EXPECT_NEAR(back.location.z, t.location.z, eps);
672 EXPECT_NEAR(back.rotation.pitch, t.rotation.pitch, eps);
673 EXPECT_NEAR(back.rotation.yaw, t.rotation.yaw, eps);
674 EXPECT_NEAR(back.rotation.roll, t.rotation.roll, eps);
675 }
676}
677
678TEST(geom, right_handed_matrix_is_s_m_s) {
679 // `RightHandedTransform::GetMatrix()` must be the similarity transform of
680 // `Transform::GetMatrix()` under a Y-axis mirror. This is the identity the
681 // Cosmos client implements as `coords.ue_to_flu(coords.ue_matrix(tf))`.
682 constexpr float eps = 1e-5f;
683 for (const auto &t : kBoundarySamples) {
684 const std::array<float, 16> expected = MirrorY(t.GetMatrix());
685 const std::array<float, 16> actual = t.ToRightHanded().GetMatrix();
686 for (size_t i = 0; i < 16; ++i) {
687 EXPECT_NEAR(actual[i], expected[i], eps)
688 << "element " << i << " for rotation " << t.rotation.pitch << "/"
689 << t.rotation.yaw << "/" << t.rotation.roll;
690 }
691 }
692}
693
694TEST(geom, right_handed_matrix_is_a_proper_rotation) {
695 // A right-handed frame means det(R) = +1 and orthonormal columns; the
696 // left-handed CARLA matrix has det = -1 once read as right-handed math,
697 // which is exactly what the mirror fixes.
698 constexpr float eps = 1e-4f;
699 for (const auto &t : kBoundarySamples) {
700 const std::array<float, 16> m = t.ToRightHanded().GetMatrix();
701 const float det =
702 m[0] * (m[5] * m[10] - m[6] * m[9]) -
703 m[1] * (m[4] * m[10] - m[6] * m[8]) +
704 m[2] * (m[4] * m[9] - m[5] * m[8]);
705 EXPECT_NEAR(det, 1.0f, eps);
706 }
707}
708
709TEST(geom, right_handed_quaternion_agrees_with_ros2_bridge) {
710 // `ros2::TransformFromCarlaRotation` has implemented this exact mapping
711 // since the ROS 2 port, independently of `geom`. It returns the quaternion
712 // as `(w, x, y, z)` and the translation already mirrored.
713 constexpr float eps = 1e-5f;
714 for (const auto &t : kBoundarySamples) {
715 const RightHandedTransform flu = t.ToRightHanded();
716 const Quaternion q = flu.GetQuaternion();
717 const auto bridge = carla::ros2::TransformFromCarlaRotation(
718 t.location.x, t.location.y, t.location.z,
719 t.rotation.pitch, t.rotation.yaw, t.rotation.roll);
720
721 EXPECT_NEAR(flu.location.x, bridge.translation[0], eps);
722 EXPECT_NEAR(flu.location.y, bridge.translation[1], eps);
723 EXPECT_NEAR(flu.location.z, bridge.translation[2], eps);
724
725 EXPECT_NEAR(q.w, bridge.rotation[0], eps) << "w";
726 EXPECT_NEAR(q.x, bridge.rotation[1], eps) << "x";
727 EXPECT_NEAR(q.y, bridge.rotation[2], eps) << "y";
728 EXPECT_NEAR(q.z, bridge.rotation[3], eps) << "z";
729 }
730}
731
732TEST(geom, right_handed_quaternion_reproduces_right_handed_matrix) {
733 // The quaternion and the matrix of the same `RightHandedTransform` must
734 // describe the same rotation, column for column, in the right-handed frame.
735 constexpr float eps = 1e-4f;
736 const RightHandedVector3D axes[] = {
737 RightHandedVector3D(1.0f, 0.0f, 0.0f),
738 RightHandedVector3D(0.0f, 1.0f, 0.0f),
739 RightHandedVector3D(0.0f, 0.0f, 1.0f)};
740
741 for (const auto &t : kBoundarySamples) {
742 const RightHandedTransform flu = t.ToRightHanded();
743 const std::array<float, 16> m = flu.GetMatrix();
744 const Quaternion q = flu.GetQuaternion();
745 for (int col = 0; col < 3; ++col) {
746 const RightHandedVector3D rotated =
747 q.RotatedVector(axes[static_cast<size_t>(col)]);
748 EXPECT_NEAR(m[static_cast<size_t>(col)], rotated.x, eps) << "col=" << col;
749 EXPECT_NEAR(m[static_cast<size_t>(4 + col)], rotated.y, eps) << "col=" << col;
750 EXPECT_NEAR(m[static_cast<size_t>(8 + col)], rotated.z, eps) << "col=" << col;
751 }
752 }
753}
754
755TEST(geom, quaternion_right_handed_rotation_round_trips) {
756 // `Quaternion` lives in the right-handed frame; `ToRightHandedRotation()`
757 // reads it back out without any sign juggling.
758 constexpr float eps = 1e-3f;
759 const RightHandedRotation samples[] = {
760 {0.0f, 0.0f, 0.0f},
761 {10.0f, -20.0f, -30.0f},
762 {-40.0f, 15.0f, 120.0f},
763 {88.0f, -62.0f, -155.0f},
764 };
765 for (const auto &r : samples) {
767 EXPECT_NEAR(back.roll, r.roll, eps);
768 EXPECT_NEAR(back.pitch, r.pitch, eps);
769 EXPECT_NEAR(back.yaw, r.yaw, eps);
770 }
771}
772
773TEST(geom, right_handed_boundary_leaves_engine_convention_untouched) {
774 // Guard: adding the adapters must not have moved the left-handed math.
775 // A camera at pitch=+20 still looks up and at roll=+25 still drops its
776 // right side (the two facts measured on UE5.8, 2026-08-27), while the
777 // right-handed view of the same poses reports the mirrored angles.
778 constexpr float eps = 1e-5f;
779
780 const Rotation pitched{20.0f, 0.0f, 0.0f};
781 EXPECT_NEAR(pitched.GetForwardVector().z, std::sin(Math::ToRadians(20.0f)), eps);
782 EXPECT_FLOAT_EQ(pitched.ToRightHanded().pitch, -20.0f);
783
784 const Rotation rolled{0.0f, 0.0f, 25.0f};
785 EXPECT_NEAR(rolled.GetRightVector().z, -std::sin(Math::ToRadians(25.0f)), eps);
786 EXPECT_FLOAT_EQ(rolled.ToRightHanded().roll, 25.0f);
787}
float distance
float y
float x
float z
geom::Point point
Definition LaneGraph.cpp:41
std::array< Location, 8 > GetLocalVertices() const
Returns the positions of the 8 vertices of this BoundingBox in local space.
std::array< Location, 8 > GetWorldVertices(const Transform &in_bbox_to_world_tr) const
Returns the positions of the 8 vertices of this BoundingBox in world space.
static constexpr T ToRadians(T deg)
Definition Math.h:43
static std::pair< float, float > DistanceSegmentToPoint(const Vector3D &p, const Vector3D &v, const Vector3D &w)
Returns a pair containing:
Definition Math.cpp:18
static auto Distance(const Vector3D &a, const Vector3D &b)
Definition Math.h:78
static std::pair< float, float > DistanceArcToPoint(Vector3D p, Vector3D start_pos, float length, float heading, float curvature)
Returns a pair containing:
Definition Math.cpp:33
Quaternion representing a 3D rotation, stored as (x, y, z, w).
Quaternion Conjugate() const
Conjugate of this quaternion.
Vector3D GetRightVector() const
Unit vector pointing toward the local Y axis of this rotation, expressed in CARLA's left-handed frame...
RightHandedRotation ToRightHandedRotation() const
This rotation as right-handed (FLU) Euler angles, degrees, intrinsic Z-Y-X.
Vector3D GetForwardVector() const
Unit vector pointing toward the local X axis of this rotation, expressed in CARLA's left-handed frame...
RightHandedVector3D RotatedVector(const RightHandedVector3D &in) const
Rotate a right-handed vector by this quaternion.
Quaternion Inverse() const
Inverse of this quaternion (assumes non-zero norm).
Vector3D GetUpVector() const
Unit vector pointing toward the local Z axis of this rotation, expressed in CARLA's left-handed frame...
Euler angles of a rotation in CARLA's / Unreal's left-handed frame: x forward, y right,...
Definition Rotation.h:45
static constexpr Rotation FromRightHanded(const RightHandedRotation &rhs)
Inverse of ToRightHanded().
Definition Rotation.h:212
Vector3D GetForwardVector() const
Definition Rotation.h:75
A rigid transform in CARLA's / Unreal's left-handed frame: x forward, y right, z up,...
static constexpr Transform FromRightHanded(const RightHandedTransform &rhs)
Inverse of ToRightHanded(): reads a right-handed (FLU) pose back into CARLA's left-handed frame.
void TransformPoint(Vector3D &in_point) const
Applies this transformation to in_point (first translation then rotation).
void InverseTransformPoint(Vector3D &in_point) const
Applies the inverse of this transformation to in_point.
std::ostream & operator<<(std::ostream &out, const Vector3D &vector3D)
Definition test_geom.cpp:28
TransformQuaternion TransformFromCarlaRotation(float tx, float ty, float tz, float pitch_deg, float yaw_deg, float roll_deg) noexcept
This file contains definitions of common data structures used in traffic manager.
Linear acceleration in metres per second squared.
Angular velocity in degrees per second.
static constexpr Location FromRightHanded(const RightHandedVector3D &rhs)
Inverse of ToRightHanded().
Euler angles of a rotation expressed in a right-handed frame, in degrees.
float yaw
Rotation about the up (+Z) axis, degrees, right-hand rule.
float roll
Rotation about the forward (+X) axis, degrees, right-hand rule.
float pitch
Rotation about the left (+Y) axis, degrees, right-hand rule.
A rigid transform expressed in a right-handed frame: x forward, y left, z up (ROS REP-103 "FLU"),...
Quaternion GetQuaternion() const
Orientation as a right-handed (x, y, z, w) quaternion.
RightHandedVector3D location
Translation in the right-handed frame, metres.
std::array< float, 16 > GetMatrix() const
Row-major 4x4 pose matrix in the right-handed frame, R = Rz(yaw) * Ry(pitch) * Rx(roll) with the stan...
RightHandedRotation rotation
Orientation in the right-handed frame: roll, pitch, yaw in degrees, right-hand rule,...
Internal adapter that holds a Vector3D in a right-handed coordinate frame.
Linear velocity in metres per second.
Definition Velocity.h:17
TEST(geom, single_point_no_transform)
Definition test_geom.cpp:38