Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci-multi-platform.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,11 @@ jobs:
env:
CC: ${{ matrix.cc }}
CXX: ${{ matrix.cxx }}
run: cmake -B build -DCMAKE_BUILD_TYPE=${{ env.BUILD_TYPE }}
run: cmake -B build -DCMAKE_BUILD_TYPE=${{ env.BUILD_TYPE }} -DBUILD_TESTING=ON

- name: Configure CMake (Windows)
if: runner.os == 'Windows'
run: cmake -B build -DCMAKE_BUILD_TYPE=${{ env.BUILD_TYPE }}
run: cmake -B build -DCMAKE_BUILD_TYPE=${{ env.BUILD_TYPE }} -DBUILD_TESTING=ON

- name: Build
run: cmake --build build --config ${{ env.BUILD_TYPE }}
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/code-coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ jobs:
run: |
cmake -B build \
-DCMAKE_BUILD_TYPE=Debug \
-DENABLE_COVERAGE=ON
-DENABLE_COVERAGE=ON \
-DBUILD_TESTING=ON

- name: Build
run: cmake --build build
Expand Down
14 changes: 13 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ project(golf VERSION 4.6.1 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# Keep the library consumable from offline or vendored builds. Tests fetch
# GoogleTest, so they are opt-in rather than a configure-time dependency for
# every library consumer.
option(BUILD_TESTING "Build the libgolf test suite" OFF)
option(GOLF_BUILD_EXAMPLES "Build libgolf examples and calibration runner" ON)

# Version information
set(GOLF_VERSION_MAJOR ${PROJECT_VERSION_MAJOR})
set(GOLF_VERSION_MINOR ${PROJECT_VERSION_MINOR})
Expand Down Expand Up @@ -83,7 +89,7 @@ if(ENABLE_COVERAGE AND NOT MSVC)
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} --coverage")
endif()

if(NOT EMSCRIPTEN)
if(BUILD_TESTING AND NOT EMSCRIPTEN)
# Include Google Test
include(FetchContent)
FetchContent_Declare(
Expand Down Expand Up @@ -121,6 +127,9 @@ if(NOT EMSCRIPTEN)
add_executable(libgolf_tests ${TEST_SOURCES})
target_link_libraries(libgolf_tests ${PROJECT_NAME} GTest::gtest_main)

endif()

if(GOLF_BUILD_EXAMPLES AND NOT EMSCRIPTEN)
# Examples
add_executable(calculate_ball_landing examples/calculate_ball_landing.cpp)
target_link_libraries(calculate_ball_landing ${PROJECT_NAME})
Expand All @@ -144,6 +153,9 @@ if(NOT EMSCRIPTEN)
add_executable(calibration_sim_runner tools/calibration/sim_runner.cpp)
target_link_libraries(calibration_sim_runner ${PROJECT_NAME})

endif()

if(BUILD_TESTING AND NOT EMSCRIPTEN)
# Discover tests
include(GoogleTest)
gtest_discover_tests(libgolf_tests)
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ chmod +x build.sh
./build.sh
```

The default build is dependency-free. To fetch GoogleTest and run the test
suite, use `./build.sh --test` (or configure CMake with `-DBUILD_TESTING=ON`).

## Using libgolf in your project

After installing (`cmake --install build`), consume it from another CMake
Expand Down
11 changes: 8 additions & 3 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,13 @@ fi
mkdir -p build
cd build

# Generate build files using CMake
cmake ..
# Generate build files using CMake. The normal library/example build has no
# network dependency; --test explicitly enables the GoogleTest-based suite.
if [ "$RUN_TESTS" -eq 1 ]; then
cmake .. -DBUILD_TESTING=ON
else
cmake ..
fi

# Build the project
cmake --build .
Expand All @@ -37,4 +42,4 @@ fi

# Optionally install (uncomment if you want to install system-wide)
# echo "Installing to system directories (password may be required)"
# sudo cmake --install .
# sudo cmake --install .
4 changes: 2 additions & 2 deletions docs/aerodynamic_model.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ The fields fall into four groups:

**Kinematic state** — `velocity`, `windVelocity`, `spinVector`, `position`, and `currentTime` are snapshots of the ball and its surrounding wind at the current timestep. The default model uses only the first three; `position` and `currentTime` are there so models with altitude-, location-, or time-dependent behaviour can reach for them.

**Ball geometry** — `ballRadius` is populated from `physics_constants::STD_BALL_RADIUS_FT`. The library does not currently support non-standard ball sizes; changing the constant is the only hook.
**Ball geometry** — `ballRadius` is derived from the `BallProperties` supplied to `FlightSimulator`; omitting that argument uses the standard golf-ball dimensions.

**Lumped atmosphere** — `c0` and `re100` are precomputed by `ShotPhysicsContext` from temperature, pressure, and humidity. They're a compact encoding for lumped-parameter force laws of the form `F = c0 · Cd(Re, S) · vw · v_rel`. The default model consumes them directly; custom models that prefer the same form can too.

Expand Down Expand Up @@ -83,7 +83,7 @@ F_magnus = c0 * (Cl / |omega|) * vw * (omega × v_rel)

**Drag** (piecewise-linear through the drag crisis):
```
Re <= RE_THRESHOLD_LOW (0.5): Cd = CD_LOW (0.500)
Re <= RE_THRESHOLD_LOW (0.5): Cd = CD_LOW (0.500) + CD_SPIN * S
RE_THRESHOLD_LOW < Re < RE_THRESHOLD_HIGH (1.0): linear + CD_SPIN * S
Re >= RE_THRESHOLD_HIGH (1.0): Cd = CD_HIGH (0.200) + CD_SPIN * S
```
Expand Down
6 changes: 5 additions & 1 deletion docs/how.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ cmake -B build
cmake --build build
```

Tests are opt-in because they fetch GoogleTest. Configure with
`cmake -B build -DBUILD_TESTING=ON` before building when you want to run them.

Include the main header in your source files:

```c++
Expand Down Expand Up @@ -126,7 +129,8 @@ printf("Bearing: %.1f degrees\n", result.bearing);
```

`LandingResult` contains:
- `xF`, `yF`, `zF` — final position in yards
- `xF`, `yF` — final lateral/downrange displacement from launch in yards
- `zF` — final height above the terrain at rest in yards (normally `0`)
- `distance` — total distance in yards
- `bearing` — direction in degrees
- `timeOfFlight` — total simulation time in seconds
Expand Down
6 changes: 4 additions & 2 deletions docs/roll_model.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ The built-in implementation lives in `include/DefaultRollModel.hpp` and is used
Algorithm:

```
a = -g·sin(θ) along slope - μ·g·cos(θ) opposing motion (Coulomb friction)
if |g_tangent| <= μ_static·N and the ball is nearly stationary: rest
else: a = g_tangent - μ_dynamic·N opposing (or initiating) motion
v' = v + a·dt
v_xy := 0 if sign flipped across the step and |v_old_xy| > ε (prevents reversal)
p' = p + v'·dt
Expand All @@ -72,7 +73,8 @@ atRest = |v'_horizontal| < STOPPING_VELOCITY
```

Where:
- `μ = surface.frictionDynamic`.
- `surface.frictionStatic` determines whether a near-stationary ball holds on
a slope; `surface.frictionDynamic` supplies Coulomb friction once it rolls.
- `θ` derived from `surfaceNormal[2]`. Surfaces with `cos(θ) > FLAT_SURFACE_THRESHOLD` skip slope decomposition.
- `STOPPING_VELOCITY = 0.1 ft/s` and `SPIN_DECAY_RATE = 2.0 rad/s²` live as `static constexpr` members on `DefaultRollModel`.

Expand Down
5 changes: 4 additions & 1 deletion docs/terrain.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,10 @@ During the roll phase, two forces act on the ball:
1. **Gravity component along slope**: Accelerates ball downhill or decelerates uphill
2. **Rolling friction**: Opposes motion in all directions

On flat surfaces, rolling friction alone causes deceleration. On slopes, the ball will accelerate if the gravity component exceeds friction.
On flat surfaces, rolling friction alone causes deceleration. A nearly stationary
ball holds while static friction can balance the downhill gravity component; it
starts rolling once that threshold is exceeded. Rolling friction then opposes
its motion.

**Spin decay during roll:** Linear decay model where ground friction applies constant torque opposing spin. This differs from aerial phase which uses exponential decay due to aerodynamic damping.

Expand Down
4 changes: 2 additions & 2 deletions docs/wasm.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ const result = libgolf.runShot(
temp: 70.0, // °F
elevation: 0.0, // ft
vWind: 10.0, // mph
phiWind: 90.0, // 0 = cross-L, 90 = head, 180 = tail, 270 = cross-R
phiWind: 90.0, // 0 = tail, 90 = crosswind to the right, 180 = head, 270 = crosswind to the left
hWind: 0.0,
relHumidity: 50.0, // %
pressure: 29.92, // inHg
Expand All @@ -90,7 +90,7 @@ const result = libgolf.runShot(

| Field | Type | Description |
|------|------|-------------|
| `trajectory` | `VectorFloat` | Flat `[x, y, z, x, y, z, ...]` array of positions in **yards** |
| `trajectory` | `VectorFloat` | Flat `[x, y, z, x, y, z, ...]` array in **yards**; x/y are relative to launch and z is above ground |
| `carryIndex` | `number` | Index (in points, not floats) of first ground contact |
| `carryYards` | `number` | Downrange distance at first ground contact |
| `totalYards` | `number` | Downrange distance at rest (carry + roll) |
Expand Down
76 changes: 57 additions & 19 deletions include/DefaultRollModel.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,25 @@ class DefaultRollModel : public RollModel
const GroundSurface &surface) const override
{
const float dt = state.dt;

const Vector3D accel = computeAcceleration(state.velocity, state.surfaceNormal, surface);

const float oldVelX = state.velocity[0];
const float oldVelY = state.velocity[1];
const float oldHorizontal = std::sqrt(oldVelX * oldVelX + oldVelY * oldVelY);

// A ball that is nearly stationary should remain at rest only when
// static friction can balance gravity along the surface. Conversely, a
// steep enough slope must start a stationary ball rolling.
if (oldHorizontal < STOPPING_VELOCITY &&
staticFrictionHolds(state.surfaceNormal, surface))
{
return RollResult{
state.position,
{0.0F, 0.0F, 0.0F},
state.spinVector,
true
};
}

const Vector3D accel = computeAcceleration(state.velocity, state.surfaceNormal, surface);

Vector3D newVel = state.velocity + accel * dt;

Expand Down Expand Up @@ -80,7 +94,6 @@ class DefaultRollModel : public RollModel
newSpin = {0.0F, 0.0F, 0.0F};
}

const float oldHorizontal = std::sqrt(oldVelX * oldVelX + oldVelY * oldVelY);
const float vHorizontal = std::sqrt(newVel[0] * newVel[0] + newVel[1] * newVel[1]);
const bool atRest = vHorizontal < STOPPING_VELOCITY &&
vHorizontal <= oldHorizontal;
Expand All @@ -89,6 +102,24 @@ class DefaultRollModel : public RollModel
}

private:
static Vector3D gravityTangent(const Vector3D &surfaceNormal)
{
const Vector3D gravity = {0.0F, 0.0F, -physics_constants::GRAVITY_FT_PER_S2};
const float gravityDotNormal = math_utils::dot(gravity, surfaceNormal);
return gravity - surfaceNormal * gravityDotNormal;
}

static bool staticFrictionHolds(const Vector3D &surfaceNormal,
const GroundSurface &surface)
{
const Vector3D tangentGravity = gravityTangent(surfaceNormal);
const float tangentMagnitude = math_utils::magnitude(tangentGravity);
const float normalForce = std::abs(
math_utils::dot(Vector3D{0.0F, 0.0F, -physics_constants::GRAVITY_FT_PER_S2},
surfaceNormal));
return tangentMagnitude <= surface.frictionStatic * normalForce;
}

static Vector3D computeAcceleration(const Vector3D &velocity,
const Vector3D &surfaceNormal,
const GroundSurface &surface)
Expand All @@ -97,13 +128,6 @@ class DefaultRollModel : public RollModel

const float vHorizontal = std::sqrt(velocity[0] * velocity[0] + velocity[1] * velocity[1]);

// Stationary: friction direction undefined, gravity along slope is
// balanced by static friction at rest.
if (vHorizontal < physics_constants::MIN_SPEED)
{
return acceleration;
}

const float cosTheta = surfaceNormal[2];

// Near-flat: skip slope decomposition.
Expand All @@ -116,17 +140,31 @@ class DefaultRollModel : public RollModel
return acceleration;
}

const Vector3D gravity = {0.0F, 0.0F, -physics_constants::GRAVITY_FT_PER_S2};
acceleration = gravityTangent(surfaceNormal);

const float gravityDotNormal = math_utils::dot(gravity, surfaceNormal);
const Vector3D gravityNormal = surfaceNormal * gravityDotNormal;
acceleration = gravity - gravityNormal;

const float normalForce = std::abs(gravityDotNormal);
const float normalForce = std::abs(
math_utils::dot(Vector3D{0.0F, 0.0F, -physics_constants::GRAVITY_FT_PER_S2},
surfaceNormal));
const float frictionDeceleration = surface.frictionDynamic * normalForce;

acceleration[0] -= frictionDeceleration * (velocity[0] / vHorizontal);
acceleration[1] -= frictionDeceleration * (velocity[1] / vHorizontal);
if (vHorizontal >= physics_constants::MIN_SPEED)
{
acceleration[0] -= frictionDeceleration * (velocity[0] / vHorizontal);
acceleration[1] -= frictionDeceleration * (velocity[1] / vHorizontal);
}
else
{
// Static friction has already failed, so kinetic friction opposes the
// impending downhill motion.
const float tangentHorizontal =
std::sqrt(acceleration[0] * acceleration[0] +
acceleration[1] * acceleration[1]);
if (tangentHorizontal >= physics_constants::MIN_SPEED)
{
acceleration[0] -= frictionDeceleration * acceleration[0] / tangentHorizontal;
acceleration[1] -= frictionDeceleration * acceleration[1] / tangentHorizontal;
}
}

return acceleration;
}
Expand Down
4 changes: 2 additions & 2 deletions include/launch_data.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ struct LaunchData
float startY = 0.0f;

/**
* @brief Starting height above ground (feet).
* @brief Starting height above the terrain at (startX, startY) (feet).
*
* Typically 0.0 for ground-level shots. Set to tee height for teed-up shots.
*/
Expand Down Expand Up @@ -119,7 +119,7 @@ struct LandingResult
float yF;

/**
* @brief Final height above ground (yards).
* @brief Final height above the terrain at the resting position (yards).
*
* Typically 0.0 when ball comes to rest on ground.
*/
Expand Down
7 changes: 3 additions & 4 deletions include/terrain_interface.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,10 @@
*
* // Use custom terrain in flight simulation
* auto terrain = std::make_shared<MyTerrain>(groundSurface);
* FlightSimulator sim(physicsVars, ball, atmos, groundSurface, terrain);
* FlightSimulator sim(launch, atmos, terrain);
*
* // Note: When a custom terrain is provided, the groundSurface parameter
* // serves as a fallback for backward compatibility. The flight simulator
* // will query terrain properties from the TerrainInterface implementation.
* // The simulator queries all ground data from TerrainInterface; there is no
* // separate fallback GroundSurface when using this constructor.
* @endcode
*
* @copyright Copyright (c) 2025, Gabriel DiFiore
Expand Down
17 changes: 15 additions & 2 deletions src/FlightSimulator.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,15 @@ FlightSimulator::FlightSimulator(
void FlightSimulator::initializeFromLaunch(const LaunchData &launch)
{
const float v0_fps = launch.ballSpeedMph * physics_constants::MPH_TO_FT_PER_S;
startPosition_ = Vector3D{launch.startX, launch.startY, launch.startZ};
// LaunchData::startZ is a tee height above the local terrain, whereas the
// simulation state always stores absolute world coordinates.
const float launchTerrainHeight =
terrainStorage_->getHeight(launch.startX, launch.startY);
startPosition_ = Vector3D{
launch.startX,
launch.startY,
launchTerrainHeight + launch.startZ
};
const Vector3D &startPos = startPosition_;

state = BallState::fromLaunchParameters(
Expand Down Expand Up @@ -153,7 +161,12 @@ LandingResult FlightSimulator::getLandingResult() const
LandingResult result;
result.xF = relative[0] / physics_constants::YARDS_TO_FEET;
result.yF = relative[1] / physics_constants::YARDS_TO_FEET;
result.zF = relative[2] / physics_constants::YARDS_TO_FEET;
// Unlike x/y, zF is a clearance above the terrain at the resting point.
// This remains zero for a ball at rest on an elevated green.
const float finalTerrainHeight =
terrainStorage_->getHeight(state.position[0], state.position[1]);
result.zF = (state.position[2] - finalTerrainHeight) /
physics_constants::YARDS_TO_FEET;
result.timeOfFlight = state.currentTime;
result.bearing = std::atan2(relative[0], relative[1]) *
180.0F / physics_constants::PI;
Expand Down
Loading