Skip to content

Commit 1988da0

Browse files
authored
Add regression coverage for issue 870 (#2283)
1 parent 3422ae4 commit 1988da0

File tree

2 files changed

+254
-0
lines changed

2 files changed

+254
-0
lines changed

tests/integration/CMakeLists.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,9 @@ dart_build_tests(
224224
simulation/test_Building.cpp
225225
)
226226

227+
dart_add_test(
228+
"integration" INTEGRATION_simulation_Issue870 simulation/test_Issue870.cpp)
229+
227230
if(TARGET dart-utils)
228231
dart_add_test("integration" INTEGRATION_simulation_FileInfoWorld simulation/test_FileInfoWorld.cpp)
229232
target_link_libraries(INTEGRATION_simulation_FileInfoWorld dart-utils)
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
/*
2+
* Copyright (c) 2011-2025, The DART development contributors
3+
* All rights reserved.
4+
*
5+
* The list of contributors can be found at:
6+
* https://github.com/dartsim/dart/blob/main/LICENSE
7+
*
8+
* This file is provided under the following "BSD-style" License:
9+
* Redistribution and use in source and binary forms, with or
10+
* without modification, are permitted provided that the following
11+
* conditions are met:
12+
* * Redistributions of source code must retain the above copyright
13+
* notice, this list of conditions and the following disclaimer.
14+
* * Redistributions in binary form must reproduce the above
15+
* copyright notice, this list of conditions and the following
16+
* disclaimer in the documentation and/or other materials provided
17+
* with the distribution.
18+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
19+
* CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
20+
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
21+
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22+
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
23+
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
24+
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
25+
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
26+
* USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
27+
* AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
28+
* LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
29+
* ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
30+
* POSSIBILITY OF SUCH DAMAGE.
31+
*/
32+
33+
#include <dart/All.hpp>
34+
35+
#include <gtest/gtest.h>
36+
37+
#include <algorithm>
38+
#include <sstream>
39+
40+
using namespace dart;
41+
using namespace dart::dynamics;
42+
using namespace dart::simulation;
43+
44+
namespace {
45+
46+
constexpr double kBoxSize = 0.12;
47+
constexpr double kBoxMass = 8.0;
48+
constexpr double kWeldedOffset = 1.6;
49+
constexpr double kFreeOffset = 0.6;
50+
51+
struct BoxBounceWorld
52+
{
53+
WorldPtr world;
54+
BodyNode* leftFree{};
55+
BodyNode* rightFree{};
56+
};
57+
58+
// Creates the exact four-box setup described in the issue:
59+
// two free boxes between two welded boxes, zero gravity, restitution 1.0.
60+
BoxBounceWorld makeFourBoxBounceWorld(double pitch)
61+
{
62+
auto world = World::create("issue870_boxes");
63+
world->setGravity(Eigen::Vector3d::Zero());
64+
// Use a small time step to keep the symmetric configuration stable.
65+
world->setTimeStep(0.00025);
66+
67+
auto makeBoxSkeleton = [&](const std::string& name,
68+
bool weld,
69+
double xTranslation) -> BodyNode* {
70+
auto skel = Skeleton::create(name);
71+
std::pair<Joint*, BodyNode*> pair;
72+
73+
if (weld) {
74+
pair = skel->createJointAndBodyNodePair<WeldJoint>();
75+
} else {
76+
pair = skel->createJointAndBodyNodePair<FreeJoint>();
77+
}
78+
79+
auto* joint = pair.first;
80+
auto* body = pair.second;
81+
82+
auto shape
83+
= std::make_shared<BoxShape>(Eigen::Vector3d::Constant(kBoxSize));
84+
auto* shapeNode = body->createShapeNodeWith<
85+
VisualAspect,
86+
CollisionAspect,
87+
DynamicsAspect>(shape);
88+
// Use elastic contact as in the original regression reproducer.
89+
shapeNode->getDynamicsAspect()->setRestitutionCoeff(1.0);
90+
shapeNode->getDynamicsAspect()->setFrictionCoeff(0.0);
91+
92+
Inertia inertia;
93+
inertia.setMass(kBoxMass);
94+
inertia.setMoment(shape->computeInertia(kBoxMass));
95+
body->setInertia(inertia);
96+
97+
Eigen::Isometry3d tf = Eigen::Isometry3d::Identity();
98+
tf.translation() = Eigen::Vector3d(xTranslation, 0.1, 0.0);
99+
tf.linear()
100+
= Eigen::AngleAxisd(pitch, Eigen::Vector3d::UnitY()).toRotationMatrix();
101+
102+
if (auto* weldJoint = dynamic_cast<WeldJoint*>(joint)) {
103+
weldJoint->setTransformFromParentBodyNode(tf);
104+
} else {
105+
joint->setPositions(FreeJoint::convertToPositions(tf));
106+
}
107+
108+
if (!weld) {
109+
auto* freeJoint = dynamic_cast<FreeJoint*>(joint);
110+
DART_ASSERT(freeJoint != nullptr);
111+
Eigen::Vector6d spatial = Eigen::Vector6d::Zero();
112+
spatial.tail<3>()
113+
= Eigen::Vector3d(xTranslation < 0 ? 1.0 : -1.0, 0.0, 0.0);
114+
freeJoint->setSpatialVelocity(spatial, Frame::World(), Frame::World());
115+
}
116+
117+
world->addSkeleton(skel);
118+
return body;
119+
};
120+
121+
BoxBounceWorld result;
122+
result.world = world;
123+
makeBoxSkeleton("box3", /*weld=*/true, -kWeldedOffset);
124+
makeBoxSkeleton("box4", /*weld=*/true, kWeldedOffset);
125+
result.leftFree = makeBoxSkeleton("box1", /*weld=*/false, -kFreeOffset);
126+
result.rightFree = makeBoxSkeleton("box2", /*weld=*/false, kFreeOffset);
127+
return result;
128+
}
129+
130+
WorldPtr makeFreeFallWorld(bool spinning)
131+
{
132+
auto world = World::create(spinning ? "spinning" : "baseline");
133+
world->setGravity(Eigen::Vector3d(0.0, -9.81, 0.0));
134+
world->setTimeStep(0.001);
135+
136+
SkeletonPtr sphere
137+
= Skeleton::create(spinning ? "spinning_sphere" : "baseline_sphere");
138+
auto pair = sphere->createJointAndBodyNodePair<FreeJoint>();
139+
auto* joint = pair.first;
140+
auto* body = pair.second;
141+
142+
auto shape = std::make_shared<EllipsoidShape>(Eigen::Vector3d::Constant(0.2));
143+
auto* shapeNode = body->createShapeNodeWith<
144+
VisualAspect,
145+
CollisionAspect,
146+
DynamicsAspect>(shape);
147+
shapeNode->getDynamicsAspect()->setFrictionCoeff(0.0);
148+
149+
const double mass = 2.0;
150+
Inertia inertia;
151+
inertia.setMass(mass);
152+
inertia.setMoment(shape->computeInertia(mass));
153+
body->setInertia(inertia);
154+
155+
Eigen::Isometry3d tf = Eigen::Isometry3d::Identity();
156+
tf.translation() = Eigen::Vector3d(0.0, 1.0, 0.0);
157+
joint->setPositions(FreeJoint::convertToPositions(tf));
158+
159+
if (spinning) {
160+
Eigen::Vector6d velocities = Eigen::Vector6d::Zero();
161+
velocities.head<3>() = Eigen::Vector3d(0.0, 0.0, 10.0);
162+
joint->setVelocities(velocities);
163+
}
164+
165+
world->addSkeleton(sphere);
166+
return world;
167+
}
168+
169+
} // namespace
170+
171+
// Regression for https://github.com/dartsim/dart/issues/870 (box bounce
172+
// symmetry): rotating every box by pi/2 should not affect the symmetric
173+
// translation of the free boxes or allow them to bypass the welded boxes.
174+
TEST(Issue870, RotatedBoxesRemainSymmetricBetweenWeldedStops)
175+
{
176+
BoxBounceWorld baseline = makeFourBoxBounceWorld(0.0);
177+
BoxBounceWorld rotated = makeFourBoxBounceWorld(dart::math::pi / 2.0);
178+
179+
const double barrier = kWeldedOffset;
180+
const double softLimit = barrier + 1e-2; // allow tiny penetration tolerance
181+
// Run long enough to cover multiple bounces without letting numerical drift
182+
// dominate the signal.
183+
// Keep total simulated time roughly consistent with the original test.
184+
const int steps = 7200;
185+
186+
double maxSymmetryError = 0.0;
187+
double maxAbsPosition = 0.0;
188+
189+
for (int i = 0; i < steps; ++i) {
190+
baseline.world->step();
191+
rotated.world->step();
192+
193+
const auto updateStats = [&](const BoxBounceWorld& w) {
194+
const auto lp = w.leftFree->getWorldTransform().translation();
195+
const auto rp = w.rightFree->getWorldTransform().translation();
196+
const auto lv
197+
= w.leftFree->getSpatialVelocity(Frame::World(), Frame::World());
198+
const auto rv
199+
= w.rightFree->getSpatialVelocity(Frame::World(), Frame::World());
200+
201+
maxSymmetryError = std::max(
202+
maxSymmetryError,
203+
std::abs((lp + rp).x()) + std::abs((lv + rv).tail<3>().x()));
204+
205+
maxAbsPosition
206+
= std::max({maxAbsPosition, std::abs(lp.x()), std::abs(rp.x())});
207+
};
208+
209+
updateStats(baseline);
210+
updateStats(rotated);
211+
}
212+
213+
std::ostringstream oss;
214+
oss << "Issue #870 metrics: maxSymmetryError=" << maxSymmetryError
215+
<< ", maxAbsPosition=" << maxAbsPosition;
216+
SCOPED_TRACE(oss.str());
217+
218+
EXPECT_LT(maxSymmetryError, 1e-4);
219+
EXPECT_LT(maxAbsPosition, softLimit);
220+
}
221+
222+
// Regression for https://github.com/dartsim/dart/issues/870: A spinning body in
223+
// free fall should not pick up lateral translation.
224+
TEST(Issue870, SpinningSphereFreeFallDoesNotDriftSideways)
225+
{
226+
auto baselineWorld = makeFreeFallWorld(false);
227+
auto spinningWorld = makeFreeFallWorld(true);
228+
229+
const int steps = 4000;
230+
double maxHorizontalSeparation = 0.0;
231+
232+
for (int i = 0; i < steps; ++i) {
233+
baselineWorld->step();
234+
spinningWorld->step();
235+
236+
const auto* baselineBody = baselineWorld->getSkeleton(0)->getRootBodyNode();
237+
const auto* spinningBody = spinningWorld->getSkeleton(0)->getRootBodyNode();
238+
239+
const Eigen::Vector3d baselinePos
240+
= baselineBody->getWorldTransform().translation();
241+
const Eigen::Vector3d spinningPos
242+
= spinningBody->getWorldTransform().translation();
243+
244+
const Eigen::Vector2d horizDiff(
245+
spinningPos.x() - baselinePos.x(), spinningPos.z() - baselinePos.z());
246+
maxHorizontalSeparation
247+
= std::max(maxHorizontalSeparation, horizDiff.norm());
248+
}
249+
250+
EXPECT_LT(maxHorizontalSeparation, 1e-7);
251+
}

0 commit comments

Comments
 (0)