
Physics-based cycling simulator converting static GPX traces into virtual rides with realistic speeds, times and power estimates; elevation correction via Terrarium DEM, smoothing, resampling and GPX output.
Kotlin Multiplatform physics-based cycling simulator: it turns a static GPS trace into a
virtualized ride with realistic speeds, times and power estimates. Elevation data comes from
Terrarium-encoded DEM tiles — mapterhorn by default — fetched and decoded by the :elevation
module.
┌──────────────┐
sample.gpx ────▶│ GpxParser │
└──────┬───────┘
▼
┌─────────────────────────────────────────┐
│ Enhancer (orchestrator) │
│ ├─ PointPerDistance(-1, 30) │
│ ├─ fixElevation (Terrarium tiles)* │
│ ├─ PointPerDistance(1, 2) │
│ ├─ smoothElevation (150 m kernel) │
│ ├─ PathCurvature (turn radius) │
│ │ or RacingLine (optimal line)* │
│ ├─ MaxSpeedComputer (cornering+braking)│
│ ├─ VirtualizeService (1 Hz physics) │
│ ├─ PointPerSecond (uniform sampling) │
│ ├─ W′bal (Critical Power annotation) │
│ ├─ PathSimplifier (Douglas-Peucker 3D) │
│ └─ ElevationGain (D+/D− dead band) │
└──────────────────┬──────────────────────┘
▼
┌──────────────┐
│ GpxWriter │────▶ output.gpx
└──────────────┘
(*) optional — needs an ElevationProvider
In: GPX tracks, routes (<rte>), segments and waypoints.
Physics: elevation correction from DEM tiles, curvature estimation or an optimal racing line, cornering and braking sharing one friction-ellipse budget, a configurable rider and bike, and a 1 Hz time-stepping simulation that produces speed, time and power at every point.
Out: GPX, Garmin FIT courses, CSV, column-oriented JSON, static PNG maps — plus climb detection, a racing-line report and worst-case wind analysis.
Four doors reach the same engine, and a capability is available from all of them unless noted:
| Capability | CLI | Kotlin / Java | JavaScript / TS | WASI |
|---|---|---|---|---|
| Run the pipeline | enhance |
Enhancer.enhanceCourseDefault |
enhance |
vcEnhance |
| Configure rider, bike, wind, power |
--cyclist-*, --bike-*, --wind-*
|
CoursePhysics(Course(…)) |
enhanceWithCourse |
vcEnhanceWithCourse |
Power models constant · durability · critical-power · from_data
|
--cyclist-model |
CyclistPowerSpec |
power.type |
power.type |
| Terrain pacing, power slew limit |
--cyclist-pacing, --cyclist-slew
|
provider decorators |
power.pacing, power.maxSlewWPerS
|
idem |
| Road condition, dry or wet | --road-condition |
Cyclist.withRoadCondition |
cyclist.roadCondition |
idem |
| Pedal-strike clearance | --bike-max-pedal-angle |
Bike.maxPedalingLeanAngleDeg |
bike.maxPedalingLeanAngleDeg |
idem |
| Racing line + corridor mode |
--racing-line, --corridor
|
RacingLineOptions |
racingLineEnabled |
idem |
| DEM elevation correction | --fix-elevation |
ElevationProvider |
fixElevation: true |
host serves tiles |
| Climb detection | — | ClimbDetector.detect |
detectClimbs |
vcDetectClimbsJson |
| Racing-line report | --racing-line-report |
RacingLine.analyze |
analyzeRacingLine |
vcAnalyzeRacingLineJson |
| Write GPX / CSV / JSON | --gpx --csv --json |
GpxWriter, CsvWriter, JsonWriter
|
writeGpx, pathToCsv, pathToJson
|
vcWriteGpx, vcPathToCsv/Json
|
| Write FIT course | --fit |
Path.toFitBytes |
pathToFit, pathsToFit
|
vcPathToFit, vcPathsToFit
|
| Static map PNG | export --map |
MapFactoriesJvm |
— | — |
Static maps are JVM-only by construction — :map draws on java.awt.
docs/ledgers/surface-coverage.md tracks this matrix as
capabilities land, so that a feature cannot reach one door and quietly miss the others.
npm install @glandais/vcyclist-engine # physics, GPX, FIT, CSV/JSON, climbs, racing line
npm install @glandais/vcyclist-elevation # DEM lookups on their ownThe engine bundle already carries the elevation façade; install the second package only if you want DEM lookups without the physics.
dependencies {
implementation("io.github.glandais:vcyclist-engine:4.2.1") // pulls -jvm / -js per target
implementation("io.github.glandais:vcyclist-elevation:4.2.1")
}<dependency>
<groupId>io.github.glandais</groupId>
<artifactId>vcyclist-engine-jvm</artifactId>
<version>4.2.1</version>
</dependency>The badges above are the source of truth for the current version. KMP consumers get the
platform-specific variant automatically; from plain Maven, name the -jvm artifact yourself.
vcyclist-gpx (the Path model + GPX I/O) comes in transitively via vcyclist-engine, and can be
depended on alone if you only need parsing and resampling. vcyclist-fit and vcyclist-map are
published separately.
The CLI is an application, not a library, so it is not on Maven Central: download
vcyclist-cli-<version>-all.jar from a GitHub release.
The WASI module is built from source (below) rather than published to a registry.
Requirements: Java 21+ for the JVM and CLI; Node ≥ 18 (22+ recommended) or Bun for JavaScript;
a WASI runtime with the function-references, gc and exceptions proposals — wasmtime 46+ is
known good.
java -jar vcyclist-cli-*-all.jar enhance route.gpx --gpx out.gpx --csv out.csvenhance runs the physics pipeline; export produces maps, FIT, CSV and JSON from a file you
already have. Elevation correction is off unless you pass --fix-elevation, so nothing touches the
network by default.
Full option reference, the rider models and what each is measured to be worth, and exit codes:
cli/README.md.
import io.github.glandais.engine.Enhancer
import io.github.glandais.engine.gpx.GpxParser
import io.github.glandais.engine.gpx.GpxWriter
import io.github.glandais.engine.gpx.firstTrackAsPath
import io.github.glandais.engine.gpx.toGpxDocument
suspend fun virtualize(xml: String): String {
val path = GpxParser.parse(xml).firstTrackAsPath()
val out = Enhancer.enhanceCourseDefault(path) // pure physics, no HTTP
return GpxWriter.write(out.toGpxDocument(trackName = "virtualized"))
}Pass an ElevationProvider as the second argument to correct elevations from DEM tiles, and an
EnhanceOptions as the third to configure the pipeline. For a configured rider, build a
CoursePhysics(Course(path, cyclist, bike), …) and call Enhancer.enhanceCourse.
Path input = GpxToPathJvm.firstTrackAsPath(GpxParserJvm.parse(xml));
Path enhanced = EnhancerJvm.enhanceCourseDefaultBlocking(input);
String out = GpxWriterJvm.write(enhanced);Every entry point has a …Jvm twin that restores Kotlin's default arguments, and every suspend
function has both a …Blocking and a …Async (CompletableFuture) bridge.
docs/guides/using-from-java.md has the calling rules — some
of them matter, …Blocking on a UI thread being the obvious one.
Kotlin/JS emits a UMD bundle that preserves the package namespace, so there is exactly one top-level export. Named imports do not work — unwrap it once:
import * as engineRaw from '@glandais/vcyclist-engine';
const engine = engineRaw.io.github.glandais.engine;
const { parseGpx, enhance, writeGpx, pathSize, pathTotalDistance } = engine;
const path = parseGpx(gpxXml);
const out = await enhance(path, null); // physics only; { fixElevation: true } for DEM
console.log(pathSize(out), pathTotalDistance(out), 'm');
// `<power>` carries the SOURCE file's power by default — writing simulated data into a format the
// ecosystem reads as a recording is the caller's call. Ask for the simulation explicitly:
const xml = writeGpx(out, true, 'computed-or-input', 'my route');docs/guides/using-from-javascript.md covers the whole
façade — enhanceWithCourse and its five DTOs, FIT and CSV/JSON export, climbs, the racing-line
report, the standalone elevation API, and the Node/Bun specifics.
:engine links a standalone WASI module, so the whole pipeline runs inside wasmtime, WasmEdge,
wazero, or an embedding in Go, Rust, Python or the JVM.
./gradlew :engine:wasmModule # -> engine/build/wasm/vcyclist-engine.wasm + .sha256The host implements three imports — read_input, write_output and fetch_tile (which may simply
answer "no tile") — and everything else is numeric exports over integer handles:
staged["bytes"] = open("ride.gpx", "rb").read()
handle = exports["vcParseGpx"](https://github.com/glandais/vcyclist/blob/develop/store, len(staged["bytes"]))
staged["bytes"] = b'{"computeOnePointPerSecond": true}'
out = exports["vcEnhance"](https://github.com/glandais/vcyclist/blob/develop/store, handle, len(staged["bytes"]))
print(exports["vcPathDurationMs"](https://github.com/glandais/vcyclist/blob/develop/store, out) / 1000, "s")This is not a reduced surface: vcEnhanceWithCourse, vcPathToFit, vcPathToCsv,
vcDetectClimbsJson and vcAnalyzeRacingLineJson are all there, and §10 of the guide is a
function-by-function parity table against the JavaScript façade.
docs/guides/wasm-wasi-abi.md is the full contract;
tools/wasi is a working host that CI runs on every pull request.
https://glandais.github.io/vcyclist — no install, runs the real engine in your browser.
A Vue 3 + Leaflet + Chart.js app with two routes, both on the same Kotlin/JS bundle:
#/ — GPX analysis: upload a route, run the physics pipeline, inspect every field on a
synchronized chart and map, with climb detection and the racing line, then download the result
as GPX or as a Garmin FIT course.#/elevation — elevation explorer: query DEM tiles at a point or along a path, with
smoothing, Douglas-Peucker simplification and hillshade/slope relief.cd demo && npm run dev # http://localhost:3000, against a locally built engineSee demo/README.md for the architecture and the static-site build.
docs/README.md is the index and says which documents are current and which are
frozen history. The short version:
docs/guides/ — how to use and extend the project: Java, JavaScript, the
WASI ABI, the racing line, the release flowdocs/ledgers/ — living state: research improvements, build warnings,
surface coveragedocs/research/ — the solo-rider simulation research reportcli/,
elevation/, map/, demo/
Open PRs against develop — the default and only long-lived branch — using
Conventional Commits. Build commands, module layout,
testing conventions and the release flow are in CONTRIBUTING.md.
Kotlin Multiplatform physics-based cycling simulator: it turns a static GPS trace into a
virtualized ride with realistic speeds, times and power estimates. Elevation data comes from
Terrarium-encoded DEM tiles — mapterhorn by default — fetched and decoded by the :elevation
module.
┌──────────────┐
sample.gpx ────▶│ GpxParser │
└──────┬───────┘
▼
┌─────────────────────────────────────────┐
│ Enhancer (orchestrator) │
│ ├─ PointPerDistance(-1, 30) │
│ ├─ fixElevation (Terrarium tiles)* │
│ ├─ PointPerDistance(1, 2) │
│ ├─ smoothElevation (150 m kernel) │
│ ├─ PathCurvature (turn radius) │
│ │ or RacingLine (optimal line)* │
│ ├─ MaxSpeedComputer (cornering+braking)│
│ ├─ VirtualizeService (1 Hz physics) │
│ ├─ PointPerSecond (uniform sampling) │
│ ├─ W′bal (Critical Power annotation) │
│ ├─ PathSimplifier (Douglas-Peucker 3D) │
│ └─ ElevationGain (D+/D− dead band) │
└──────────────────┬──────────────────────┘
▼
┌──────────────┐
│ GpxWriter │────▶ output.gpx
└──────────────┘
(*) optional — needs an ElevationProvider
In: GPX tracks, routes (<rte>), segments and waypoints.
Physics: elevation correction from DEM tiles, curvature estimation or an optimal racing line, cornering and braking sharing one friction-ellipse budget, a configurable rider and bike, and a 1 Hz time-stepping simulation that produces speed, time and power at every point.
Out: GPX, Garmin FIT courses, CSV, column-oriented JSON, static PNG maps — plus climb detection, a racing-line report and worst-case wind analysis.
Four doors reach the same engine, and a capability is available from all of them unless noted:
| Capability | CLI | Kotlin / Java | JavaScript / TS | WASI |
|---|---|---|---|---|
| Run the pipeline | enhance |
Enhancer.enhanceCourseDefault |
enhance |
vcEnhance |
| Configure rider, bike, wind, power |
--cyclist-*, --bike-*, --wind-*
|
CoursePhysics(Course(…)) |
enhanceWithCourse |
vcEnhanceWithCourse |
Power models constant · durability · critical-power · from_data
|
--cyclist-model |
CyclistPowerSpec |
power.type |
power.type |
| Terrain pacing, power slew limit |
--cyclist-pacing, --cyclist-slew
|
provider decorators |
power.pacing, power.maxSlewWPerS
|
idem |
| Road condition, dry or wet | --road-condition |
Cyclist.withRoadCondition |
cyclist.roadCondition |
idem |
| Pedal-strike clearance | --bike-max-pedal-angle |
Bike.maxPedalingLeanAngleDeg |
bike.maxPedalingLeanAngleDeg |
idem |
| Racing line + corridor mode |
--racing-line, --corridor
|
RacingLineOptions |
racingLineEnabled |
idem |
| DEM elevation correction | --fix-elevation |
ElevationProvider |
fixElevation: true |
host serves tiles |
| Climb detection | — | ClimbDetector.detect |
detectClimbs |
vcDetectClimbsJson |
| Racing-line report | --racing-line-report |
RacingLine.analyze |
analyzeRacingLine |
vcAnalyzeRacingLineJson |
| Write GPX / CSV / JSON | --gpx --csv --json |
GpxWriter, CsvWriter, JsonWriter
|
writeGpx, pathToCsv, pathToJson
|
vcWriteGpx, vcPathToCsv/Json
|
| Write FIT course | --fit |
Path.toFitBytes |
pathToFit, pathsToFit
|
vcPathToFit, vcPathsToFit
|
| Static map PNG | export --map |
MapFactoriesJvm |
— | — |
Static maps are JVM-only by construction — :map draws on java.awt.
docs/ledgers/surface-coverage.md tracks this matrix as
capabilities land, so that a feature cannot reach one door and quietly miss the others.
npm install @glandais/vcyclist-engine # physics, GPX, FIT, CSV/JSON, climbs, racing line
npm install @glandais/vcyclist-elevation # DEM lookups on their ownThe engine bundle already carries the elevation façade; install the second package only if you want DEM lookups without the physics.
dependencies {
implementation("io.github.glandais:vcyclist-engine:4.2.1") // pulls -jvm / -js per target
implementation("io.github.glandais:vcyclist-elevation:4.2.1")
}<dependency>
<groupId>io.github.glandais</groupId>
<artifactId>vcyclist-engine-jvm</artifactId>
<version>4.2.1</version>
</dependency>The badges above are the source of truth for the current version. KMP consumers get the
platform-specific variant automatically; from plain Maven, name the -jvm artifact yourself.
vcyclist-gpx (the Path model + GPX I/O) comes in transitively via vcyclist-engine, and can be
depended on alone if you only need parsing and resampling. vcyclist-fit and vcyclist-map are
published separately.
The CLI is an application, not a library, so it is not on Maven Central: download
vcyclist-cli-<version>-all.jar from a GitHub release.
The WASI module is built from source (below) rather than published to a registry.
Requirements: Java 21+ for the JVM and CLI; Node ≥ 18 (22+ recommended) or Bun for JavaScript;
a WASI runtime with the function-references, gc and exceptions proposals — wasmtime 46+ is
known good.
java -jar vcyclist-cli-*-all.jar enhance route.gpx --gpx out.gpx --csv out.csvenhance runs the physics pipeline; export produces maps, FIT, CSV and JSON from a file you
already have. Elevation correction is off unless you pass --fix-elevation, so nothing touches the
network by default.
Full option reference, the rider models and what each is measured to be worth, and exit codes:
cli/README.md.
import io.github.glandais.engine.Enhancer
import io.github.glandais.engine.gpx.GpxParser
import io.github.glandais.engine.gpx.GpxWriter
import io.github.glandais.engine.gpx.firstTrackAsPath
import io.github.glandais.engine.gpx.toGpxDocument
suspend fun virtualize(xml: String): String {
val path = GpxParser.parse(xml).firstTrackAsPath()
val out = Enhancer.enhanceCourseDefault(path) // pure physics, no HTTP
return GpxWriter.write(out.toGpxDocument(trackName = "virtualized"))
}Pass an ElevationProvider as the second argument to correct elevations from DEM tiles, and an
EnhanceOptions as the third to configure the pipeline. For a configured rider, build a
CoursePhysics(Course(path, cyclist, bike), …) and call Enhancer.enhanceCourse.
Path input = GpxToPathJvm.firstTrackAsPath(GpxParserJvm.parse(xml));
Path enhanced = EnhancerJvm.enhanceCourseDefaultBlocking(input);
String out = GpxWriterJvm.write(enhanced);Every entry point has a …Jvm twin that restores Kotlin's default arguments, and every suspend
function has both a …Blocking and a …Async (CompletableFuture) bridge.
docs/guides/using-from-java.md has the calling rules — some
of them matter, …Blocking on a UI thread being the obvious one.
Kotlin/JS emits a UMD bundle that preserves the package namespace, so there is exactly one top-level export. Named imports do not work — unwrap it once:
import * as engineRaw from '@glandais/vcyclist-engine';
const engine = engineRaw.io.github.glandais.engine;
const { parseGpx, enhance, writeGpx, pathSize, pathTotalDistance } = engine;
const path = parseGpx(gpxXml);
const out = await enhance(path, null); // physics only; { fixElevation: true } for DEM
console.log(pathSize(out), pathTotalDistance(out), 'm');
// `<power>` carries the SOURCE file's power by default — writing simulated data into a format the
// ecosystem reads as a recording is the caller's call. Ask for the simulation explicitly:
const xml = writeGpx(out, true, 'computed-or-input', 'my route');docs/guides/using-from-javascript.md covers the whole
façade — enhanceWithCourse and its five DTOs, FIT and CSV/JSON export, climbs, the racing-line
report, the standalone elevation API, and the Node/Bun specifics.
:engine links a standalone WASI module, so the whole pipeline runs inside wasmtime, WasmEdge,
wazero, or an embedding in Go, Rust, Python or the JVM.
./gradlew :engine:wasmModule # -> engine/build/wasm/vcyclist-engine.wasm + .sha256The host implements three imports — read_input, write_output and fetch_tile (which may simply
answer "no tile") — and everything else is numeric exports over integer handles:
staged["bytes"] = open("ride.gpx", "rb").read()
handle = exports["vcParseGpx"](https://github.com/glandais/vcyclist/blob/develop/store, len(staged["bytes"]))
staged["bytes"] = b'{"computeOnePointPerSecond": true}'
out = exports["vcEnhance"](https://github.com/glandais/vcyclist/blob/develop/store, handle, len(staged["bytes"]))
print(exports["vcPathDurationMs"](https://github.com/glandais/vcyclist/blob/develop/store, out) / 1000, "s")This is not a reduced surface: vcEnhanceWithCourse, vcPathToFit, vcPathToCsv,
vcDetectClimbsJson and vcAnalyzeRacingLineJson are all there, and §10 of the guide is a
function-by-function parity table against the JavaScript façade.
docs/guides/wasm-wasi-abi.md is the full contract;
tools/wasi is a working host that CI runs on every pull request.
https://glandais.github.io/vcyclist — no install, runs the real engine in your browser.
A Vue 3 + Leaflet + Chart.js app with two routes, both on the same Kotlin/JS bundle:
#/ — GPX analysis: upload a route, run the physics pipeline, inspect every field on a
synchronized chart and map, with climb detection and the racing line, then download the result
as GPX or as a Garmin FIT course.#/elevation — elevation explorer: query DEM tiles at a point or along a path, with
smoothing, Douglas-Peucker simplification and hillshade/slope relief.cd demo && npm run dev # http://localhost:3000, against a locally built engineSee demo/README.md for the architecture and the static-site build.
docs/README.md is the index and says which documents are current and which are
frozen history. The short version:
docs/guides/ — how to use and extend the project: Java, JavaScript, the
WASI ABI, the racing line, the release flowdocs/ledgers/ — living state: research improvements, build warnings,
surface coveragedocs/research/ — the solo-rider simulation research reportcli/,
elevation/, map/, demo/
Open PRs against develop — the default and only long-lived branch — using
Conventional Commits. Build commands, module layout,
testing conventions and the release flow are in CONTRIBUTING.md.