pure-cpp 1.0.0
A C++ physics simulation benchmark comparing performance with Python implementations
space.cpp
Go to the documentation of this file.
1/**
2 * \file space.cpp
3 * \brief Implementation of the n-body simulation space, handling physics and
4 * collisions.
5 * \author Le Bars, Yoann
6 *
7 * This file is part of the pure C++ benchmark.
8 *
9 * This program is free software: you can redistribute it and/or modify it
10 * under the terms of the GNU General Public License as published by the Free
11 * Software Foundation, either version 3 of the License, or (at your option)
12 * any later version.
13 *
14 * This program is distributed in the hope that it will be useful, but WITHOUT
15 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
17 * more details.
18 *
19 * You should have received a copy of the GNU General Public License along with
20 * this program. If not, see <https://www.gnu.org/licenses/>.
21 */
22
23#include "space.hpp"
24
25#include <omp.h>
26
27#include <Eigen/StdVector>
28#include <QDebug>
29#include <algorithm>
30#include <chrono>
31#include <cmath>
32#include <random>
33
34#include "pcg_random.hpp"
35
36// Type alias for profiling clock (used throughout the file).
37using ProfilingClock = std::chrono::high_resolution_clock;
38
39Model::Space::Space(const Configuration::SimulationConfig& config)
40 : dt_(config.max_dt),
41 previous_dt_(config.max_dt),
42 G_(config.universal_g),
43 epsilon_(config.epsilon),
44 coeffRestitution_(config.coeff_restitution),
45 coeffFriction_(config.coeff_friction),
46 coeffStaticFriction_(config.coeff_static_friction),
47 positionalCorrectionFactor_(config.positional_correction_factor),
48 linearDamping_(config.linear_damping),
49 angularDamping_(config.angular_damping),
50 diagnosticsEnabled_(config.diagnostics_enabled),
51 logFreq_(config.log_freq) {
52 initializeBodies(config.n_bodies, config.dens, config.seed);
53 dt_ = previous_dt_ = config.max_dt; // Set initial dt after initialisation
54}
55
56namespace {
57 // Bring Eigen types into this namespace for the PlacementGrid.
58 using Eigen::Vector3d;
59 using Eigen::Vector3i;
60
61 /**
62 * \brief A simple hash function for Eigen::Vector3i to allow its use as a
63 * key in std::unordered_map.
64 */
65 struct Vector3iHash {
66 std::size_t operator()(const Vector3i& v) const {
67 // A common way to combine hashes for vector types.
68 std::hash<int> hasher;
69 return hasher(v.x()) ^ (hasher(v.y()) << 1) ^ (hasher(v.z()) << 2);
70 }
71 };
72
73 /**
74 * \brief A spatial grid for Poisson Disk Sampling (Bridson's algorithm).
75 *
76 * This grid is used to accelerate overlap checks during Poisson Disk
77 * Sampling. Each cell stores the index of a point (or -1 if empty).
78 */
79 class PoissonDiskGrid {
80 public:
81 /// \brief The grid data: grid_[i][j][k] = point index or -1 if empty.
82 std::vector<std::vector<std::vector<int>>> grid_;
83
84 /// \brief The size of each grid cell.
85 double cell_size_;
86
87 /// \brief The dimensions of the grid (number of cells in each
88 /// dimension).
89 Vector3i grid_shape_;
90
91 /// \brief The domain dimensions (side length of the cube).
92 Vector3d dims_;
93
94 PoissonDiskGrid(double in_cell_size, const Vector3d& in_dims)
95 : cell_size_(in_cell_size), dims_(in_dims) {
96 // Calculate grid shape (number of cells in each dimension)
97 grid_shape_ = Vector3i(
98 static_cast<int>(std::ceil(in_dims.x() / in_cell_size)),
99 static_cast<int>(std::ceil(in_dims.y() / in_cell_size)),
100 static_cast<int>(std::ceil(in_dims.z() / in_cell_size)));
101
102 // Initialize grid with -1 (empty)
103 grid_.resize(grid_shape_.x());
104 for (int i = 0; i < grid_shape_.x(); ++i) {
105 grid_[i].resize(grid_shape_.y());
106 for (int j = 0; j < grid_shape_.y(); ++j) {
107 grid_[i][j].resize(grid_shape_.z(), -1);
108 }
109 }
110 }
111
112 /**
113 * \brief Gets the grid cell coordinates for a position.
114 * \param pos The 3D position.
115 * \return The cell coordinates as a Vector3i.
116 */
117 Vector3i getCellCoords(const Vector3d& pos) const {
118 // Convert position to grid coordinates (shift to positive domain)
119 const Vector3d shifted = pos + dims_ / 2.0;
120 return Vector3i(static_cast<int>(shifted.x() / cell_size_),
121 static_cast<int>(shifted.y() / cell_size_),
122 static_cast<int>(shifted.z() / cell_size_));
123 }
124
125 /**
126 * \brief Checks if a position is valid (within domain and not
127 * overlapping).
128 * \param pos The candidate position.
129 * \param min_dist_sq The minimum squared distance required.
130 * \param existing_points The vector of existing points.
131 * \return True if the position is valid.
132 */
133 bool isValid(const Vector3d& pos, double min_dist_sq,
134 const std::vector<Vector3d>& existing_points) const {
135 // Check if within domain
136 if (std::abs(pos.x()) >= dims_.x() / 2.0 ||
137 std::abs(pos.y()) >= dims_.y() / 2.0 ||
138 std::abs(pos.z()) >= dims_.z() / 2.0) {
139 return false;
140 }
141
142 // Get cell coordinates
143 const Vector3i cell = getCellCoords(pos);
144
145 // Check bounds
146 if (cell.x() < 0 || cell.x() >= grid_shape_.x() || cell.y() < 0 ||
147 cell.y() >= grid_shape_.y() || cell.z() < 0 ||
148 cell.z() >= grid_shape_.z()) {
149 return false;
150 }
151
152 // Check if cell is already occupied
153 if (grid_[cell.x()][cell.y()][cell.z()] != -1) {
154 return false;
155 }
156
157 // Check 3x3x3 neighborhood for nearby points
158 for (int i = -1; i <= 1; ++i) {
159 for (int j = -1; j <= 1; ++j) {
160 for (int k = -1; k <= 1; ++k) {
161 const int x = cell.x() + i;
162 const int y = cell.y() + j;
163 const int z = cell.z() + k;
164
165 if (x >= 0 && x < grid_shape_.x() && y >= 0 &&
166 y < grid_shape_.y() && z >= 0 &&
167 z < grid_shape_.z()) {
168 const int point_idx = grid_[x][y][z];
169 if (point_idx != -1) {
170 const double dist_sq =
171 (pos - existing_points[point_idx])
172 .squaredNorm();
173 if (dist_sq < min_dist_sq) {
174 return false;
175 }
176 }
177 }
178 }
179 }
180 }
181
182 return true;
183 }
184
185 /**
186 * \brief Adds a point to the grid.
187 * \param point_idx The index of the point in the points array.
188 * \param pos The position of the point.
189 */
190 void add(std::size_t point_idx, const Vector3d& pos) {
191 const Vector3i cell = getCellCoords(pos);
192 if (cell.x() >= 0 && cell.x() < grid_shape_.x() && cell.y() >= 0 &&
193 cell.y() < grid_shape_.y() && cell.z() >= 0 &&
194 cell.z() < grid_shape_.z()) {
195 grid_[cell.x()][cell.y()][cell.z()] =
196 static_cast<int>(point_idx);
197 }
198 }
199 };
200} // namespace
201
203 // Clean up the OpenMP locks.
204 for (std::size_t i = 0; i < graphLocks_.size(); ++i) {
205 omp_destroy_lock(&graphLocks_[i]);
206 }
207}
208
209void Model::Space::initializeBodies(std::size_t n, double dens,
210 unsigned int seed) {
211 // Validate input parameters
212 if (n == 0) {
213 throw std::invalid_argument("Number of bodies must be greater than 0");
214 }
215 if (dens <= 0.0) {
216 throw std::invalid_argument("Density must be greater than 0");
217 }
218 if (!std::isfinite(dens)) {
219 throw std::invalid_argument("Density must be a finite number");
220 }
221
222 if (seed == 0) {
223 /* Random device to create a seed. */
224 std::random_device rd;
225 seed = rd();
226 }
227 // Use the PCG generator instead of mt19937
228 Rng::Pcg32 gen(seed); // NOLINT
229 // Random values for bodies' masses.
230 std::uniform_real_distribution<> massDist(MIN_BODY_MASS, MAX_BODY_MASS);
231
232 /* Pre-generate all random properties in vectors, mirroring the
233 vectorised approach in the Python version. */
234 std::vector<double> masses(n);
235 std::vector<double> radii(n);
236 std::vector<Vector3d> velocities(n);
237 std::vector<Vector3d> angularVelocities(n);
238 std::uniform_real_distribution<> vDis(MIN_INITIAL_VELOCITY,
240 std::uniform_real_distribution<> omegaDis(-MAX_INITIAL_ANGULAR_VELOCITY,
242 for (std::size_t i = 0; i < n; ++i) {
243 masses[i] = massDist(gen);
244 radii[i] = masses[i] * dens;
245 velocities[i] = Vector3d(vDis(gen), vDis(gen), vDis(gen));
246 angularVelocities[i] =
247 Vector3d(omegaDis(gen), omegaDis(gen), omegaDis(gen));
248 }
249
250 // --- Poisson Disk Sampling for Body Placement ---
251 /* Use Poisson Disk Sampling (Bridson's algorithm) to generate
252 non-overlapping positions, matching the Python implementation.
253 This produces a more uniform and stable initial distribution. */
254 const double avgMass = (MIN_BODY_MASS + MAX_BODY_MASS) / 2.0;
255 const double avgRadius = avgMass * dens;
256 const double placementScale =
257 std::cbrt(static_cast<double>(n)) * avgRadius * PLACEMENT_SCALE_FACTOR;
258 const Vector3d dims(2.0 * placementScale, 2.0 * placementScale,
259 2.0 * placementScale);
260
261 // Find maximum radius for minimum distance calculation
262 const double maxRadius = *std::max_element(radii.begin(), radii.end());
263 const double minDist = 2.0 * maxRadius; // Minimum distance between centers
264 const double minDistSq = minDist * minDist;
265
266 // Cell size for acceleration grid
267 const double cellSize = minDist / std::sqrt(3.0);
268 PoissonDiskGrid grid(cellSize, dims);
269
270 // Generate positions using Poisson Disk Sampling
271 std::vector<Vector3d> positions;
272 positions.reserve(n);
273 std::vector<std::size_t> activeList;
274 constexpr int kRejectionSamples = 30;
275
276 // Start with a single random point
277 std::uniform_real_distribution<> initialDist(-placementScale,
278 placementScale);
279 Vector3d initialPoint(initialDist(gen), initialDist(gen), initialDist(gen));
280 positions.push_back(initialPoint);
281 grid.add(0, initialPoint);
282 activeList.push_back(0);
283
284 // Generate subsequent points
285 constexpr double TWO_PI = 2.0 * 3.14159265358979323846;
286 std::uniform_real_distribution<> angleDist(0.0, TWO_PI);
287 std::uniform_real_distribution<> radiusDist(minDist, 2.0 * minDist);
288
289 while (!activeList.empty() && positions.size() < n) {
290 // Choose a random active point
291 std::uniform_int_distribution<std::size_t> activeDist(
292 0, activeList.size() - 1);
293 const std::size_t activeIdx = activeList[activeDist(gen)];
294 const Vector3d& activePoint = positions[activeIdx];
295 bool foundCandidate = false;
296
297 // Try to generate a valid candidate around the active point
298 for (int attempt = 0; attempt < kRejectionSamples; ++attempt) {
299 // Generate random point in annulus around active point
300 // Using spherical coordinates
301 const double theta = angleDist(gen); // Azimuthal angle
302 const double phi = angleDist(gen); // Polar angle
303 const double radius = radiusDist(gen);
304
305 Vector3d offset(radius * std::sin(phi) * std::cos(theta),
306 radius * std::sin(phi) * std::sin(theta),
307 radius * std::cos(phi));
308 const Vector3d candidate = activePoint + offset;
309
310 // Check if candidate is valid (within domain and non-overlapping)
311 if (grid.isValid(candidate, minDistSq, positions)) {
312 positions.push_back(candidate);
313 grid.add(positions.size() - 1, candidate);
314 activeList.push_back(positions.size() - 1);
315 foundCandidate = true;
316 break;
317 }
318 }
319
320 // If no valid candidate found, remove from active list
321 if (!foundCandidate) {
322 activeList.erase(
323 std::remove(activeList.begin(), activeList.end(), activeIdx),
324 activeList.end());
325 }
326 }
327
328 // Handle case where not all bodies could be placed
329 const std::size_t nPlaced = positions.size();
330 if (nPlaced < n) {
331 qWarning().noquote()
332 << "Failed to generate" << n
333 << "non-overlapping positions. The density may be too high. Using"
334 << nPlaced << "bodies instead.";
335 }
336
337 // Place bodies using the generated positions
338 bodies_.reserve(nPlaced);
339 for (std::size_t i = 0; i < nPlaced; ++i) {
340 bodies_.emplaceBack(masses[i], radii[i], positions[i], velocities[i],
341 angularVelocities[i]);
342 }
343
344 // Initialise the collision graph, its locks, and the spatial grid.
345 // Use nPlaced instead of n in case not all bodies could be placed.
346 const std::size_t numBodies = nPlaced;
347 collisionGraph_.resize(numBodies);
348 graphLocks_.resize(numBodies);
349 for (std::size_t i = 0; i < numBodies; ++i) {
350 omp_init_lock(&graphLocks_[i]);
351 }
352 /* Initialise thread-local collision pairs storage.
353 This size is fixed for the lifetime of the Space object. */
354 thread_local_collision_pairs_.resize(omp_get_max_threads());
355
356 /* Pre-allocate some memory for the thread-local collision vectors to reduce
357 reallocations during the simulation loop. The exact number is a
358 heuristic. */
359 const std::size_t reserve_size = numBodies / omp_get_max_threads();
360 for (auto& vec : thread_local_collision_pairs_) {
361 vec.reserve(reserve_size);
362 }
363
364 /* To avoid duplicating force calculation logic, we run one step of the
365 dynamics computation. This correctly calculates initial accelerations
366 and handles any initial collisions.
367 To prevent numerical explosions from bodies starting too close, we run a
368 few “settling” steps with a very small time step. This allows the system
369 to reach a more stable state before the main simulation begins. */
370 const double originalDt = dt_; // Save the real time step.
371 kdTree_ = std::make_unique<KDTree>(bodies_);
372 previous_dt_ = epsilon_; // Use a tiny time step for settling.
373
374 for (int i = 0; i < SETTLING_STEPS; ++i) {
375 computeDynamics(0); // Iteration count doesn't matter for settling
376 // For settling, we force the next dt to be the same small value.
377 dt_ = previous_dt_;
378 }
379
380 dt_ = previous_dt_ = originalDt; // Restore the real time step.
381}
382
383std::tuple<Model::Vector3dVec, Model::QuaterniondVec>
385 const std::size_t numBodies = n();
386 Vector3dVec positions;
387 QuaterniondVec quaternions;
388
389 positions.reserve(numBodies);
390 quaternions.reserve(numBodies);
391
392 positions.assign(bodies_.x_.begin(), bodies_.x_.begin() + numBodies);
393 quaternions.assign(bodies_.q_.begin(), bodies_.q_.begin() + numBodies);
394
395 return {positions, quaternions};
396}
397
398std::tuple<Model::Vector3dVec, Model::QuaterniondVec, Model::Vector3dVec,
399 Model::Vector3dVec>
401 const std::size_t numBodies = n();
402 Vector3dVec positions;
403 QuaterniondVec quaternions;
404 Vector3dVec torques;
405 Vector3dVec alphas;
406
407 positions.assign(bodies_.x_.begin(), bodies_.x_.begin() + numBodies);
408 quaternions.assign(bodies_.q_.begin(), bodies_.q_.begin() + numBodies);
409 torques.assign(bodies_.torque_.begin(),
410 bodies_.torque_.begin() + numBodies);
411 alphas.assign(bodies_.alpha_.begin(), bodies_.alpha_.begin() + numBodies);
412
413 return {positions, quaternions, torques, alphas};
414}
415
416void Model::Space::printProfilingReport() const {
417 const double totalTime = profIntegration_ + profCollisionGraph_ +
418 profCollisionResponse_ + profForceCalculation_;
419
420 if (totalTime < epsilon_) {
421 qDebug().noquote() << "\n--- Profiling Report (No data) ---";
422 return;
423 }
424
425 const QString msg =
426 QString(
427 "\n--- Profiling Report ---\nIntegration: %1s "
428 "(%2%%)\nCollision Graph: %3s (%4%%)\nCollision Response: "
429 "%5s (%6%%)\nForce Calculation: %7s "
430 "(%8%%)\n------------------------\nTotal Instrumented Time:%9s")
431 .arg(profIntegration_, 9, 'f', 4)
432 .arg(profIntegration_ / totalTime * 100.0, 5, 'f', 1)
433 .arg(profCollisionGraph_, 9, 'f', 4)
434 .arg(profCollisionGraph_ / totalTime * 100.0, 5, 'f', 1)
435 .arg(profCollisionResponse_, 9, 'f', 4)
436 .arg(profCollisionResponse_ / totalTime * 100.0, 5, 'f', 1)
437 .arg(profForceCalculation_, 9, 'f', 4)
438 .arg(profForceCalculation_ / totalTime * 100.0, 5, 'f', 1)
439 .arg(totalTime, 9, 'f', 4);
440 qDebug().noquote() << msg;
441}
442
443std::tuple<double, double, double> Model::Space::calculateSystemEnergy() const {
444 double transKe = 0.0;
445 double rotKe = 0.0;
446 double potential = 0.0;
447
448 // --- Kinetic Energy Calculation (Vectorized) ---
449 // This loop is perfectly parallel, as each body's KE is independent.
450#pragma omp parallel for reduction(+ : transKe, rotKe)
451 for (std::size_t i = 0; i < n(); ++i) {
452 const auto bi = body(i);
453 // Linear kinetic energy: 0.5 * m * v^2
454 transKe += 0.5 * bi.m() * bi.v().squaredNorm();
455 /* Rotational kinetic energy: 0.5 * I * w^2.
456 Since I = 1.0 / getInverseInertia(), this is 0.5 * (1 /
457 getInverseInertia()) * w^2. */
458 if (bi.getInverseInertia() > 0) {
459 rotKe +=
460 0.5 * (1.0 / bi.getInverseInertia()) * bi.omega().squaredNorm();
461 }
462 }
463
464 // --- Gravitational Potential Energy Calculation ---
465 /* This O(N^2) calculation is kept separate to allow for better parallel
466 load balancing of its triangular workload. This is a reduction over
467 `potential`. */
468#pragma omp parallel for reduction(+ : potential) schedule(dynamic)
469 for (std::size_t i = 0; i < n(); ++i) {
470 const auto bi = body(i);
471 /* To avoid double-counting, we only calculate for pairs (i, j) where
472 j > i. */
473 for (std::size_t j = i + 1; j < n(); ++j) {
474 const auto bj = body(j);
475 const double dSq = (bi.x() - bj.x()).squaredNorm();
476 if (dSq > epsilon_ * epsilon_) {
477 potential -= G_ * bi.m() * bj.m() / std::sqrt(dSq);
478 }
479 }
480 }
481
482 return {transKe, rotKe, potential};
483}
484
486 /* The total correction is distributed between the two bodies based on
487 their inverse mass. */
488 const double totalInvMass =
489 ctx.b1.getInverseMass() + ctx.b2.getInverseMass();
490 if (totalInvMass < epsilon_) {
491 return;
492 }
493
494 // A small percentage of the overlap is corrected to avoid jittering.
495 constexpr double PERCENT = 0.8; // 80% correction
496 const Vector3d correction =
497 (ctx.overlap_ * PERCENT / totalInvMass) * ctx.nVec_;
498
499 if (ctx.b1.getInverseMass() > 0) { // NOLINT
500 ctx.b1.addToX(-correction * ctx.b1.getInverseMass()); // NOLINT
501 }
502 if (ctx.b2.getInverseMass() > 0) { // NOLINT
503 ctx.b2.addToX(correction * ctx.b2.getInverseMass()); // NOLINT
504 }
505}
506
508 const Vector3d& direction_vec) {
509 // Rotational inertia contribution for body 1: (r1 x n)^2 / I1
510 const double term1 = (ctx.r1Vec_.cross(direction_vec)).squaredNorm() *
511 ctx.b1.getInverseInertia(); // NOLINT
512 // Rotational inertia contribution for body 2: (r2 x n)^2 / I2
513 const double term2 = (ctx.r2Vec_.cross(direction_vec)).squaredNorm() *
514 ctx.b2.getInverseInertia(); // NOLINT
515
516 const double effectiveMassInv = ctx.b1.getInverseMass() +
517 ctx.b2.getInverseMass() + term1 +
518 term2; // NOLINT
519 return effectiveMassInv > epsilon_ ? 1.0 / effectiveMassInv : 0.0;
520}
521
522std::tuple<Model::Vector3d, double, double>
524 const double vRelNormal = ctx.vRel_.dot(ctx.nVec_);
525 if (vRelNormal > 0) {
526 // Bodies are already moving apart, no restitution needed.
527 return {Vector3d::Zero(), 0.0, 0.0};
528 }
529 const double effectiveMassN = getEffectiveMass(ctx, ctx.nVec_);
530
531 // --- Baumgarte Stabilisation ---
532 // Calculate the positional error correction impulse (bias).
533 const double bias_jn = (positionalCorrectionFactor_ / previous_dt_) *
534 std::max(0.0, ctx.overlap_ - 0.01) * effectiveMassN;
535
536 /* Impulse for restitution (velocity change), based only on the normal
537 velocity. */
538 const double restitution_jn =
539 -(1.0 + coeffRestitution_) * vRelNormal * effectiveMassN;
540
541 /* Total impulse magnitude including positional correction.
542 The physical restitution impulse cannot be attractive (negative). */
543 const double jn = std::max(0.0, restitution_jn) + bias_jn;
544
545 return {jn * ctx.nVec_, jn, bias_jn}; // NOLINT
546}
547
549 double jn,
550 const Vector3d& impulseN) {
551 /* Calculate the tangential component of the INITIAL relative velocity.
552 Since impulses are applied simultaneously, the friction impulse must
553 oppose the initial tangential velocity to ensure energy dissipation.
554 The work done is: W = impulseT · vT_initial, and we need W < 0. */
555 const Vector3d vT_initial =
556 ctx.vRel_ - ctx.vRel_.dot(ctx.nVec_) * ctx.nVec_;
557 const double vT_initialNorm = vT_initial.norm();
558
559 // If there’s no tangential velocity, there’s no friction.
560 if (vT_initialNorm < epsilon_) {
561 return Vector3d::Zero();
562 }
563
564 // Direction of tangential motion (based on initial relative velocity)
565 const Vector3d tVec = vT_initial / vT_initialNorm;
566 const double effectiveMassT = getEffectiveMass(ctx, tVec); // NOLINT
567
568 /* Calculate the relative velocity after the restitution impulse is applied.
569 This is used to determine the magnitude of friction needed. */
570 const Vector3d delta_v1 = -impulseN * ctx.b1.getInverseMass();
571 const Vector3d delta_v2 = impulseN * ctx.b2.getInverseMass();
572
573 // Calculate the torques generated by the restitution impulse
574 const Vector3d torque1 = ctx.r1Vec_.cross(-impulseN);
575 const Vector3d torque2 = ctx.r2Vec_.cross(impulseN);
576
577 // Calculate the change in angular velocities
578 const Vector3d delta_omega1 = ctx.b1.getInverseInertia() * torque1;
579 const Vector3d delta_omega2 = ctx.b2.getInverseInertia() * torque2;
580
581 // Calculate the relative velocity after restitution
582 const Vector3d vRelAfterRestitution =
583 ((ctx.b2.v() + delta_v2) +
584 (ctx.b2.omega() + delta_omega2).cross(ctx.r2Vec_)) -
585 ((ctx.b1.v() + delta_v1) +
586 (ctx.b1.omega() + delta_omega1).cross(ctx.r1Vec_));
587
588 /* Project the tangential velocity after restitution onto the initial
589 tangential direction to determine the magnitude needed. */
590 const Vector3d vTAfterRestitution =
591 vRelAfterRestitution - vRelAfterRestitution.dot(ctx.nVec_) * ctx.nVec_;
592 const double vTAfterRestitutionAlongT = vTAfterRestitution.dot(tVec);
593
594 /* Calculate the magnitude of the impulse required to stop the tangential
595 motion along the initial tangential direction. */
596 const double jtRequiredMagnitude =
597 std::abs(vTAfterRestitutionAlongT) * effectiveMassT;
598
599 // --- Coulomb’s Law of Friction ---
600 /* The friction limit should be based on the physical normal force, not
601 including the positional correction bias. Calculate the physical
602 restitution impulse magnitude (without bias) for the friction limit. */
603 const double vRelNormal = ctx.vRel_.dot(ctx.nVec_);
604 const double effectiveMassN = getEffectiveMass(ctx, ctx.nVec_);
605 const double restitution_jn =
606 (vRelNormal <= 0) ? std::max(0.0, -(1.0 + coeffRestitution_) *
607 vRelNormal * effectiveMassN)
608 : 0.0;
609 /* Use only the physical restitution impulse for friction limit, not the
610 bias. However, if jn_physical is very small (e.g., when vRelNormal ≈ 0),
611 use the provided jn as a fallback to allow friction testing in cases
612 where the normal velocity component is negligible. */
613 const double jn_physical = restitution_jn;
614 const double jn_for_friction = (jn_physical < epsilon_) ? jn : jn_physical;
615 const double staticFrictionLimit = coeffStaticFriction_ * jn_for_friction;
616
617 double jt; // The magnitude of the tangential impulse (signed).
618 if (jtRequiredMagnitude < staticFrictionLimit) {
619 /* Static friction: the impulse magnitude is exactly what's required
620 to stop the motion. The impulse must oppose the direction of
621 motion. */
622 jt = jtRequiredMagnitude;
623 } else {
624 /* Kinetic friction: the impulse magnitude is the kinetic friction
625 limit. The impulse must oppose the direction of motion.
626 Use jn_for_friction which falls back to jn if jn_physical is too
627 small. */
628 jt = coeffFriction_ * jn_for_friction;
629 }
630 /* Return the impulse. tVec points in the direction of INITIAL tangential
631 motion. jt is positive and represents the magnitude needed.
632 The impulse must oppose the initial tangential motion to ensure energy
633 dissipation. The work done by friction is: W = impulseT · vT_initial.
634 For energy dissipation, we need W < 0, so impulseT must oppose
635 vT_initial. Since tVec = vT_initial / |vT_initial|, we need impulseT =
636 -jt * tVec to ensure it opposes the initial motion direction. This
637 guarantees W = (-jt * tVec) · (|vT_initial| * tVec) = -jt * |vT_initial|
638 < 0. */
639 return -jt * tVec;
640}
641
642std::optional<Model::CollisionContext> Model::Space::createCollisionContext(
643 BodyProxy& b1, BodyProxy& b2) {
644 const Vector3d dVec = b2.x() - b1.x();
645 const double distSq = dVec.squaredNorm();
646 const double rSum = b1.r() + b2.r();
647
648 if (distSq >= rSum * rSum || distSq < epsilon_ * epsilon_) {
649 return std::nullopt;
650 }
651
652 const double dist = std::sqrt(distSq);
653 const Vector3d nVec = dVec / dist;
654 const Vector3d r1Vec = b1.r() * nVec;
655 const Vector3d r2Vec = -b2.r() * nVec;
656 const Vector3d vRel =
657 (b2.v() + b2.omega().cross(r2Vec)) - (b1.v() + b1.omega().cross(r1Vec));
658 const double overlap = (b1.r() + b2.r()) - dist;
659
660 return CollisionContext{b1, b2, nVec, r1Vec,
661 r2Vec, vRel, overlap}; // NOLINT
662}
663
665 auto context_opt = createCollisionContext(b1, b2);
666 if (!context_opt) {
667 return;
668 }
669 CollisionContext& ctx = *context_opt;
670
671 // Resolve interpenetration by moving bodies apart. This modifies body
672 // positions, which invalidates the collision context (vRel_, overlap_,
673 // r1Vec_, r2Vec_, nVec_ all depend on positions).
674 resolveInterpenetration(ctx);
675
676 // Recreate the collision context with updated positions to ensure accurate
677 // impulse calculations. This matches the approach used in the unit tests.
678 auto context_opt_updated = createCollisionContext(b1, b2);
679 if (!context_opt_updated) {
680 return;
681 }
682 CollisionContext& ctx_updated = *context_opt_updated;
683
684 const auto [impulseN, jn, bias_jn] = applyRestitutionImpulse(ctx_updated);
685
686 // The friction impulse is only applied if there is a contact impulse.
687 if (jn > 0) {
688 const Vector3d impulseT =
689 applyFrictionImpulse(ctx_updated, jn, impulseN);
690 const Vector3d totalImpulse = impulseN + impulseT;
691 const Vector3d torque1 =
692 ctx_updated.b1.applyImpulse(-totalImpulse, ctx_updated.r1Vec_);
693 const Vector3d torque2 =
694 ctx_updated.b2.applyImpulse(totalImpulse, ctx_updated.r2Vec_);
695 ctx_updated.b1.accumulateTorque(torque1);
696 ctx_updated.b2.accumulateTorque(torque2);
697 }
698}
699
701 const std::vector<std::vector<std::size_t>>& in_graph) {
702 // Implementation of a parallel greedy graph colouring algorithm.
703 // See space.hpp for detailed documentation and examples.
704 const std::size_t numBodies = n();
705 colors_.assign(numBodies, 0); // Reset colors to 0
706 std::vector<bool> conflicts(numBodies, true);
707 bool hasConflicts = true;
708
709 // A lock to protect writes to colors_ (only needed for thread-safe writes)
710 omp_lock_t color_lock;
711 omp_init_lock(&color_lock);
712 while (hasConflicts) {
713 hasConflicts = false; // Assume no conflicts until one is found.
714
715 // Calculate max_color once before the parallel loop to avoid
716 // recalculating it N times inside the lock.
717 // Note: This is a lower bound; usedColors will be resized if needed.
718 const int max_color = *std::max_element(colors_.begin(), colors_.end());
719
720 // Pass 1: Colour nodes in parallel based on current neighbour colours.
721#pragma omp parallel for
722 for (std::size_t i = 0; i < numBodies; ++i) {
723 if (!conflicts[i]) continue;
724
725 // Read neighbour colours under lock to avoid data races.
726 // We need to synchronize reads because other threads may be writing
727 // to colors_ concurrently.
728 std::vector<int> neighbourColours;
729 neighbourColours.reserve(in_graph[i].size());
730 omp_set_lock(&color_lock);
731 for (const auto& neighbourIdx : in_graph[i]) {
732 neighbourColours.push_back(colors_[neighbourIdx]);
733 }
734 omp_unset_lock(&color_lock);
735
736 // Create usedColors vector locally for each thread (no lock needed)
737 // Start with max_color + 2, but resize if we encounter larger
738 // colors
739 std::vector<bool> usedColors(max_color + 2, false);
740 for (const int neighbourColour : neighbourColours) {
741 // Resize if necessary to handle colors larger than initial
742 // max_color
743 if (neighbourColour >= static_cast<int>(usedColors.size())) {
744 usedColors.resize(neighbourColour + 2, false);
745 }
746 usedColors[neighbourColour] = true;
747 }
748
749 int newColor = 0; // Find the smallest available colour.
750 while (newColor < static_cast<int>(usedColors.size()) &&
751 usedColors[newColor]) {
752 newColor++;
753 }
754 // If we've exhausted all colors in usedColors, we need a new one
755 // (this should be rare, but can happen in race conditions)
756 if (newColor >= static_cast<int>(usedColors.size())) {
757 // This color is guaranteed to be available since it's beyond
758 // all currently used colors
759 }
760
761 // Lock when writing to colors_ (minimal lock time)
762 omp_set_lock(&color_lock);
763 colors_[i] = newColor;
764 omp_unset_lock(&color_lock);
765 }
766
767 // Pass 2: Detect conflicts in parallel
768 // Mark both bodies in a conflicting pair to ensure both are recolored.
769 // The condition i < neighbourIdx was used to avoid double-counting,
770 // but it prevented higher-indexed bodies from being marked, breaking
771 // the algorithm. We now mark both bodies when they share a color.
772#pragma omp parallel for reduction(| : hasConflicts)
773 for (std::size_t i = 0; i < numBodies; ++i) {
774 conflicts[i] = false;
775 for (const auto& neighbourIdx : in_graph[i]) {
776 if (colors_[i] == colors_[neighbourIdx]) {
777 conflicts[i] = true;
778 hasConflicts = true;
779 break; // One conflict is enough to mark this body
780 }
781 }
782 }
783 }
784
785 omp_destroy_lock(&color_lock);
786}
787
788void Model::Space::logSystemEnergy(std::size_t iteration) {
789 if (!diagnosticsEnabled_ || iteration == 0 || iteration % logFreq_ != 0) {
790 return;
791 }
792
793 const auto [transKe, rotKe, potential] = calculateSystemEnergy();
794 const double totalEnergy = transKe + rotKe + potential;
795
796 // Single string so the logger does not insert spaces between tokens.
797 const QString msg =
798 QString(
799 "\n--- Iteration %1: System Energy ---\n Translational KE: %2\n "
800 "Rotational KE: %3\n Potential Energy: %4\n Total Energy: "
801 "%5\n Time Step (dt): %6")
802 .arg(iteration)
803 .arg(transKe, 12, 'e', 4)
804 .arg(rotKe, 12, 'e', 4)
805 .arg(potential, 12, 'e', 4)
806 .arg(totalEnergy, 12, 'e', 4)
807 .arg(getPreviousTimeStep(), 12, 'e', 4);
808 qDebug().noquote() << msg;
809
810 // Check for energy increase, which indicates instability.
811 if (totalEnergy > lastTotalEnergy_ + epsilon_) {
812 qWarning().noquote()
813 << "\n========================================"
814 << "\n Total energy increased by "
815 << QString::asprintf("%.4e", totalEnergy - lastTotalEnergy_)
816 << "\n========================================";
817 }
818 lastTotalEnergy_ = totalEnergy;
819}
820
822 // Implementation of two-phase collision detection (broad-phase +
823 // narrow-phase). See space.hpp for detailed documentation and examples.
824 const std::size_t numBodies = n();
825 // Defensive check: Ensure the collision graph is sized correctly.
826 if (collisionGraph_.size() != numBodies) {
827 collisionGraph_.resize(numBodies);
828 }
829
830 // Clear previous frame's data.
831#pragma omp parallel for
832 for (std::size_t i = 0; i < numBodies; ++i) {
833 collisionGraph_[i].clear();
834 }
835
836 // Clear thread-local collision pairs storage. This must be done separately
837 // because thread_local_collision_pairs_ is indexed by thread ID, not body
838 // index.
839#pragma omp parallel for
840 for (int tid = 0;
841 tid < static_cast<int>(thread_local_collision_pairs_.size()); ++tid) {
842 thread_local_collision_pairs_[tid].clear();
843 }
844
845 // Rebuild the k-d tree with the new positions for broad-phase detection.
846 kdTree_ = std::make_unique<KDTree>(bodies_);
847
848 // Find the maximum radius for a safe search radius.
849 // Cache it to avoid recalculating every frame (radii don't change during
850 // simulation, only positions do). Recalculate only if cache is invalid.
851 if (cached_max_radius_ == 0.0) {
852 cached_max_radius_ = 0.0;
853#pragma omp parallel for reduction(max : cached_max_radius_)
854 for (std::size_t i = 0; i < numBodies; ++i) {
855 cached_max_radius_ = std::max(cached_max_radius_, bodies_.r_[i]);
856 }
857 }
858 const double max_radius = cached_max_radius_;
859
860 // Find all potentially colliding pairs using thread-local storage.
861#pragma omp parallel
862 {
863 const int tid = omp_get_thread_num();
864 // Pre-allocate results vector to avoid repeated allocations.
865 // Reserve space for a reasonable number of neighbours (heuristic: 50).
866#if defined(NANOFLANN_VERSION) && NANOFLANN_VERSION < 0x150
867 std::vector<std::pair<std::size_t, double>> results;
868#else
869 std::vector<nanoflann::ResultItem<std::size_t, double>> results;
870#endif
871 results.reserve(50);
872
873#pragma omp for schedule(dynamic)
874 for (std::size_t i = 0; i < numBodies; ++i) {
875 // Access SoA data directly to avoid BodyProxy overhead.
876 const double r_i = bodies_.r_[i];
877 const Vector3d& x_i = bodies_.x_[i];
878 const double search_radius = r_i + max_radius;
879
880 // Clear results vector for reuse (capacity is preserved).
881 results.clear();
882 kdTree_->radiusSearch(x_i.data(), search_radius, results);
883
884 // Narrow-Phase: Check actual collisions.
885 // Access SoA data directly to avoid repeated BodyProxy creation.
886 for (const auto& result : results) {
887 const std::size_t j = result.first;
888 if (i >= j) continue;
889
890 const double rSum = r_i + bodies_.r_[j];
891 const double distSq = (x_i - bodies_.x_[j]).squaredNorm();
892 if (distSq < rSum * rSum) {
893 thread_local_collision_pairs_[tid].push_back({i, j});
894 }
895 }
896 }
897 }
898
899 // Merge thread-local results into the global graph.
900 for (const auto& localPairs : thread_local_collision_pairs_) {
901 for (const auto& pair : localPairs) {
902 collisionGraph_[pair.first].emplace_back(pair.second);
903 collisionGraph_[pair.second].emplace_back(pair.first);
904 }
905 }
906}
907
908void Model::Space::computeDynamics(std::size_t iteration) {
909 // --- 1. Diagnostics ---
910 logSystemEnergy(iteration);
911
912 // --- 2. Integration (Part 1) ---
913 /* Update positions based on the previous frame's state.
914 Capture dt_ before it may be modified by updateAdaptiveTimeStep().
915 Both integratePart1 and integratePart2 must use the same dt for the
916 Velocity Verlet scheme to be correct. */
917 const double current_dt = dt_;
918 {
919 const auto startTime = ProfilingClock::now();
920 bodies_.integratePart1(dt_, previous_dt_);
921 profIntegration_ +=
922 std::chrono::duration<double>(ProfilingClock::now() - startTime)
923 .count();
924 }
925
926 // --- 3. Collision Detection ---
927 {
928 const auto startTime = ProfilingClock::now();
929 buildCollisionGraph();
930 profCollisionGraph_ +=
931 std::chrono::duration<double>(ProfilingClock::now() - startTime)
932 .count();
933 }
934
935 // --- 4. Collision Response ---
936 {
937 const auto startTime = ProfilingClock::now();
938 resolveCollisions();
939 profCollisionResponse_ +=
940 std::chrono::duration<double>(ProfilingClock::now() - startTime)
941 .count();
942 }
943
944 // --- 5. Adaptive Time Stepping ---
945 updateAdaptiveTimeStep();
946
947 // --- 6. Force Calculation ---
948 {
949 const auto startTime = ProfilingClock::now();
950 applyGravity();
951 profForceCalculation_ +=
952 std::chrono::duration<double>(ProfilingClock::now() - startTime)
953 .count();
954 }
955
956 // --- 7. Integration (Part 2) ---
957 /* Complete the velocity update using the new accelerations.
958 Use the same dt that was used in integratePart1 to maintain Velocity
959 Verlet correctness. */
960 bodies_.integratePart2(current_dt);
961
962 // --- 8. Damping ---
963 applyDamping();
964}
965
967 const std::size_t numBodies = n();
968 if (numBodies == 0) return;
969
970 // Colour the graph to partition collisions into independent sets.
971 colorGraph(collisionGraph_);
972 const int numColours =
973 colors_.empty() ? 0
974 : *std::max_element(colors_.begin(), colors_.end()) + 1;
975
976 // Process collisions in parallel, one colour at a time.
977 for (int c = 0; c < numColours; ++c) {
978#pragma omp parallel for schedule(dynamic)
979 for (std::size_t i = 0; i < numBodies; ++i) {
980 if (colors_[i] != c) continue;
981
982 for (const auto& j : collisionGraph_[i]) {
983 // To avoid double-processing, only handle pair (i, j) if i < j.
984 if (i < j) {
985 auto b1 = body(i);
986 auto b2 = body(j);
987 handleCollision(b1, b2);
988 }
989 }
990 }
991 }
992}
993
995 const std::size_t numBodies = n();
996 // Use direct SoA access to avoid BodyProxy overhead, similar to
997 // buildCollisionGraph() optimization.
998#pragma omp parallel for
999 for (std::size_t i = 0; i < numBodies; ++i) {
1000 bodies_.v_[i] *= (1.0 - linearDamping_);
1001 bodies_.omega_[i] *= (1.0 - angularDamping_);
1002 }
1003}
1004
1006 // Store the dt we just used for this frame's integration.
1007 previous_dt_ = dt_;
1008
1009 // Find the maximum squared acceleration in the system.
1010 double maxASq = 0.;
1011#pragma omp parallel for reduction(max : maxASq) schedule(static)
1012 for (std::size_t i = 0; i < n(); ++i) {
1013 const double aSq = body(i).a().squaredNorm();
1014 maxASq = std::max(maxASq, aSq);
1015 }
1016
1017 // Find the maximum squared velocity (CFL condition).
1018 double maxVSq = 0.0;
1019 std::size_t fastestBodyIdx = 0;
1020 for (std::size_t i = 0; i < n(); ++i) {
1021 const double vSq = body(i).v().squaredNorm();
1022 if (vSq > maxVSq) {
1023 maxVSq = vSq;
1024 fastestBodyIdx = i;
1025 }
1026 }
1027
1028 // Find the maximum squared angular velocity and angular acceleration.
1029 double maxOmegaSq = 0.;
1030 double maxAlphaSq = 0.;
1031#pragma omp parallel for reduction(max : maxOmegaSq, maxAlphaSq) \
1032 schedule(static)
1033 for (std::size_t i = 0; i < n(); ++i) {
1034 const double omegaSq = body(i).omega().squaredNorm();
1035 const double alphaSq = body(i).alpha().squaredNorm();
1036 maxOmegaSq = std::max(maxOmegaSq, omegaSq);
1037 maxAlphaSq = std::max(maxAlphaSq, alphaSq);
1038 }
1039
1040 // Adapt dt for the next frame based on the current maximum acceleration.
1041 double dtA;
1042 if (maxASq > epsilon_ * epsilon_) {
1043 dtA = std::sqrt(2.0 * TARGET_DX / std::sqrt(maxASq));
1044 } else {
1045 dtA = MAX_DT;
1046 }
1047
1048 double dtV;
1049 if (maxVSq > epsilon_ * epsilon_) {
1050 dtV = (0.5 * body(fastestBodyIdx).r()) / std::sqrt(maxVSq);
1051 } else {
1052 dtV = MAX_DT;
1053 }
1054
1055 // Rotational constraints: prevent bodies from rotating too far in a single
1056 // step. Max allowed rotation in radians (approx. 5.7 degrees), matching
1057 // Python implementation.
1058 constexpr double MAX_ANGLE = 0.1;
1059
1060 double dtOmega;
1061 if (maxOmegaSq > epsilon_ * epsilon_) {
1062 // Angular velocity constraint: omega * dt < max_angle
1063 // => dt < max_angle / sqrt(omega²)
1064 // => dt² < max_angle² / omega²
1065 dtOmega = MAX_ANGLE / std::sqrt(maxOmegaSq);
1066 } else {
1067 dtOmega = MAX_DT;
1068 }
1069
1070 double dtAlpha;
1071 if (maxAlphaSq > epsilon_ * epsilon_) {
1072 // Angular acceleration constraint: 0.5 * alpha * dt² < max_angle
1073 // => dt² < 2 * max_angle / sqrt(alpha²)
1074 // => dt < sqrt(2 * max_angle / sqrt(alpha²))
1075 dtAlpha = std::sqrt(2.0 * MAX_ANGLE / std::sqrt(maxAlphaSq));
1076 } else {
1077 dtAlpha = MAX_DT;
1078 }
1079
1080 // Combine all constraints and apply damping to smooth the change.
1081 const double targetDt = std::min({MAX_DT, dtA, dtV, dtOmega, dtAlpha});
1082 dt_ = dt_ * (1.0 - DT_DAMPING_FACTOR) + targetDt * DT_DAMPING_FACTOR;
1083}
1084
1086#pragma omp parallel for
1087 for (std::size_t i = 0; i < n(); ++i) {
1088 body(i).resetAccelerations();
1089 /* Note: Do not reset torque here, as it may contain torques accumulated
1090 from collision responses. The torque will be converted to angular
1091 acceleration below, and then reset for the next frame. */
1092 }
1093
1094#pragma omp parallel for schedule(dynamic)
1095 for (std::size_t i = 0; i < n(); ++i) {
1096 Vector3d totalForceVec = Vector3d::Zero();
1097 for (std::size_t j = 0; j < n(); ++j) {
1098 if (i == j) continue;
1099 const Vector3d dv = body(j).x() - body(i).x();
1100 const double dSq = dv.squaredNorm();
1101 if (dSq > epsilon_ * epsilon_) {
1102 const double force_mag =
1103 G_ * body(j).m() / (dSq * std::sqrt(dSq));
1104 totalForceVec += dv * force_mag;
1105 }
1106 }
1107 body(i).setAcceleration(totalForceVec);
1108 }
1109
1110 /* Convert accumulated torque to angular acceleration.
1111 This includes torques from collision responses accumulated in
1112 handleCollision(). */
1113#pragma omp parallel for
1114 for (std::size_t i = 0; i < n(); ++i) {
1115 body(i).updateAlphaFromTorque();
1116 }
1117
1118 /* Reset torque after converting to angular acceleration, preparing for
1119 the next frame. */
1120#pragma omp parallel for
1121 for (std::size_t i = 0; i < n(); ++i) {
1122 body(i).resetTorque();
1123 }
1124}
const Vector3d & v() const noexcept
Access to body velocities.
Definition: body.hpp:101
double getInverseInertia() const
Access to the inverse of the body's moment of inertia.
Definition: body.hpp:83
double getInverseMass() const
Access to the inverse of the body masses.
Definition: body.hpp:67
double r() const noexcept
Access to body radii.
Definition: body.hpp:76
const Vector3d & x() const noexcept
Access to body positions.
Definition: body.hpp:92
A proxy object that provides an AoS-like interface to a body stored in the Bodies SoA container.
Definition: body.hpp:141
void addToX(const Vector3d &correction)
Add a displacement to a position.
Definition: body.hpp:510
Vector3d & omega()
Access to body angular velocities.
Definition: body.hpp:504
void accumulateTorque(const Vector3d &torque)
Accumulates a torque vector to the body's total torque.
Definition: body.hpp:544
Vector3d applyImpulse(const Vector3d &in_J, const Vector3d &in_r_vec)
Apply an impulse to a body.
Definition: body.hpp:514
Vector3d applyFrictionImpulse(const CollisionContext &ctx, double jn, const Vector3d &impulseN)
Calculates the tangential (frictional) impulse for a collision.
Definition: space.cpp:548
~Space()
Destructor to clean up OpenMP locks.
Definition: space.cpp:202
double previous_dt_
Previous time step (in s).
Definition: space.hpp:569
void initializeBodies(std::size_t n, double dens, unsigned int seed)
Initialises the bodies in the simulation with random properties.
Definition: space.cpp:209
void applyGravity()
Calculates and applies gravitational forces to all bodies.
Definition: space.cpp:1085
std::tuple< Vector3dVec, QuaterniondVec, Vector3dVec, Vector3dVec > getDataForDisplay() const
Retrieves all data required for display for all bodies.
Definition: space.cpp:400
void resolveInterpenetration(const CollisionContext &ctx)
Resolves interpenetration by moving bodies apart along the collision normal.
Definition: space.cpp:485
double getEffectiveMass(const CollisionContext &ctx, const Vector3d &direction_vec)
Calculates the effective mass of two colliding bodies in a given direction.
Definition: space.cpp:507
void logSystemEnergy(std::size_t iteration)
Logs system energy and checks for instability if diagnostics are enabled.
Definition: space.cpp:788
void resolveCollisions()
Resolves all detected collisions using graph colouring.
Definition: space.cpp:966
void colorGraph(const std::vector< std::vector< std::size_t > > &graph)
A greedy graph colouring algorithm to assign a colour (integer) to each body such that no two adjacen...
Definition: space.cpp:700
void computeDynamics(std::size_t iteration)
Computes one full step of the simulation.
Definition: space.cpp:908
Space()
Default constructor.
Definition: space.hpp:96
std::optional< CollisionContext > createCollisionContext(BodyProxy &b1, BodyProxy &b2)
Factory method to create a CollisionContext if two bodies are colliding.
Definition: space.cpp:642
double dt_
Time step (in s).
Definition: space.hpp:566
void applyDamping()
Applies linear and angular damping to all bodies.
Definition: space.cpp:994
void updateAdaptiveTimeStep()
Determines the optimal time step for the next frame based on current velocities and accelerations.
Definition: space.cpp:1005
std::tuple< Vector3d, double, double > applyRestitutionImpulse(CollisionContext &ctx)
Calculates and applies the normal impulse (restitution) for a collision.
Definition: space.cpp:523
void buildCollisionGraph()
Builds the collision graph using broad-phase and narrow-phase detection.
Definition: space.cpp:821
std::tuple< Vector3dVec, QuaterniondVec > getAllBodyTransforms() const
Retrieves the current positions and orientations of all bodies.
Definition: space.cpp:384
void handleCollision(BodyProxy &b1, BodyProxy &b2)
Handles collision detection and response between two bodies.
Definition: space.cpp:664
std::tuple< double, double, double > calculateSystemEnergy() const
Calculates the kinetic and potential energy of the system.
Definition: space.cpp:443
A 32-bit Permuted Congruential Generator (pcg32).
Definition: pcg_random.hpp:37
constexpr double MIN_BODY_MASS
Minimum mass for randomly generated bodies.
Definition: constants.hpp:73
constexpr double MIN_INITIAL_VELOCITY
Minimum initial velocity component for randomly generated bodies.
Definition: constants.hpp:79
constexpr double MAX_BODY_MASS
Maximum mass for randomly generated bodies.
Definition: constants.hpp:76
constexpr double TARGET_DX
Heuristic for the maximum distance a body should travel in one step, used for adaptive time stepping.
Definition: constants.hpp:39
constexpr double PLACEMENT_SCALE_FACTOR
Heuristic scaling factor for the initial placement volume of bodies.
Definition: constants.hpp:90
constexpr double MAX_INITIAL_VELOCITY
Maximum initial velocity component for randomly generated bodies.
Definition: constants.hpp:82
constexpr double DT_DAMPING_FACTOR
Damping factor for smoothing adaptive time step changes.
Definition: constants.hpp:56
constexpr double MAX_INITIAL_ANGULAR_VELOCITY
Maximum initial angular velocity component for randomly generated bodies (in rad/s).
Definition: constants.hpp:86
constexpr int SETTLING_STEPS
The number of "settling" steps to run at the start of the simulation.
Definition: constants.hpp:94
constexpr double MAX_DT
Maximum time step allowed for the simulation to ensure stability.
Definition: constants.hpp:42
A minimal C++ implementation of the PCG32 random number generator.
N-body simulation space with gravitational interaction and collision response.
A data container to hold all relevant information for a single collision event.
Definition: space.hpp:67
Vector3d vRel_
The relative velocity between the two bodies at the contact point.
Definition: space.hpp:80
BodyProxy & b1
A proxy to the first body in the collision.
Definition: space.hpp:69
BodyProxy & b2
A proxy to the second body in the collision.
Definition: space.hpp:71
Vector3d r1Vec_
The vector from the centre of body 1 to the contact point.
Definition: space.hpp:75
Vector3d r2Vec_
The vector from the centre of body 2 to the contact point.
Definition: space.hpp:77
Vector3d nVec_
The normalised vector pointing from body 1 to body 2.
Definition: space.hpp:73
double overlap_
The penetration depth of the two bodies.
Definition: space.hpp:82