@glandais/vcyclist-engine 4.2.1 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,11 +8,10 @@
8
8
  [![Maven Central fit](https://img.shields.io/maven-central/v/io.github.glandais/vcyclist-fit?label=io.github.glandais%3Avcyclist-fit)](https://central.sonatype.com/artifact/io.github.glandais/vcyclist-fit)
9
9
  [![Maven Central map](https://img.shields.io/maven-central/v/io.github.glandais/vcyclist-map?label=io.github.glandais%3Avcyclist-map)](https://central.sonatype.com/artifact/io.github.glandais/vcyclist-map)
10
10
 
11
- Kotlin Multiplatform port of [`@glandais/virtual-cyclist`](https://github.com/glandais/virtual-cyclist):
12
- physics-based cycling simulator that turns a static GPS trace into a virtualized ride with
13
- realistic speeds, times and power estimates. Inspired by [gpx2web](https://github.com/glandais/gpx2web)
14
- (Java) for the physics model and the [`@glandais/elevation`](https://github.com/glandais/elevation)
15
- TypeScript library for elevation data.
11
+ Kotlin Multiplatform physics-based cycling simulator: it turns a static GPS trace into a
12
+ virtualized ride with realistic speeds, times and power estimates. Elevation data comes from
13
+ Terrarium-encoded DEM tiles — mapterhorn by default — fetched and decoded by the `:elevation`
14
+ module.
16
15
 
17
16
  ```
18
17
  ┌──────────────┐
@@ -31,7 +30,8 @@ TypeScript library for elevation data.
31
30
  │ ├─ VirtualizeService (1 Hz physics) │
32
31
  │ ├─ PointPerSecond (uniform sampling) │
33
32
  │ ├─ W′bal (Critical Power annotation) │
34
- │ └─ PathSimplifier (Douglas-Peucker 3D) │
33
+ │ ├─ PathSimplifier (Douglas-Peucker 3D) │
34
+ │ └─ ElevationGain (D+/D− dead band) │
35
35
  └──────────────────┬──────────────────────┘
36
36
  ▼
37
37
  ┌──────────────┐
@@ -40,358 +40,223 @@ TypeScript library for elevation data.
40
40
  (*) optional — needs an ElevationProvider
41
41
  ```
42
42
 
43
- ## Modules
43
+ ## What it can do
44
44
 
45
- | Module | Purpose | Targets |
46
- |---|---|---|
47
- | **`:elevation`** | Terrarium tile fetch + DEM lookup + Haversine + Douglas-Peucker 3D + triangular smoother. See [`elevation/README.md`](elevation/README.md). | JVM, JS Node, JS browser, WASI |
48
- | **`:gpx`** | Path model (38 fields × `DoubleArray`), resamplers, Douglas-Peucker simplifier, elevation steps, GPX I/O. Published to Maven Central; **not** published to npm — its JS output ships inside `@glandais/vcyclist-engine`. | JVM, JS Node, JS browser, WASI |
49
- | **`:engine`** | Physics (4 resistive `PowerProvider`s + cyclist input + `MaxSpeedComputer` + `VirtualizeService`), `Enhancer` pipeline, JVM CLI, JS façades. Re-exports `:gpx` via `api`, so `io.github.glandais.engine.path.*` and `…engine.gpx.*` stay importable from `:engine`. Also the module that links the standalone `.wasm` — see [`docs/wasm-wasi-abi.md`](docs/wasm-wasi-abi.md). | JVM, JS Node, JS browser, WASI |
50
- | **`:fit`** | Garmin FIT encoding. `FitCourse` model, unit conversions and `FitEncoder` itself, all in commonMain over [`fit-kotlin-sdk`](https://github.com/glandais/fit-kotlin-sdk) — a multiplatform SDK generated from the FIT profile. One encoder, byte-identical output on every target, and no vendor SDK for consumers to install. | JVM, JS Node, JS browser, WASI |
51
- | **`:map`** | Static map rendering: Web Mercator projection, image framing, tile download + cache, PNG output (`java.awt` / `ImageIO`). **JVM-only.** No default tile source — see [`map/README.md`](map/README.md) for the usage-policy obligations. | JVM only |
52
- | **`:cli`** | Command-line tool (picocli). **JVM-only, not published as a library** — distributed as an executable jar. Replaces gpx2web's `gpxtools-cli`. | JVM only |
53
- | **`:codegen`** | Tiny build-time helper that regenerates `GeneratedPath.kt` + `PointFieldAccessors.kt` from `PointField` (run only when the field list changes). | JVM only |
45
+ **In**: GPX tracks, routes (`<rte>`), segments and waypoints.
54
46
 
55
- ### Running it without a JVM or a JavaScript host
47
+ **Physics**: elevation correction from DEM tiles, curvature estimation or an optimal racing line,
48
+ cornering and braking sharing one friction-ellipse budget, a configurable rider and bike, and a
49
+ 1 Hz time-stepping simulation that produces speed, time and power at every point.
56
50
 
57
- `:engine` also links a standalone **WASI module**, so the whole pipeline runs inside wasmtime,
58
- WasmEdge, wazero, or an embedding in Go, Rust, Python or the JVM — no JavaScript involved.
51
+ **Out**: GPX, Garmin FIT courses, CSV, column-oriented JSON, static PNG maps — plus climb
52
+ detection, a racing-line report and worst-case wind analysis.
59
53
 
60
- ```bash
61
- ./gradlew :engine:wasmModule # -> engine/build/wasm/vcyclist-engine.wasm + .sha256
62
- ```
63
-
64
- ```python
65
- handle = exports["vcParseGpx"](store, len(gpx_bytes)) # after wiring three imports
66
- print(exports["vcPathTotalDistance"](store, handle), "m")
67
- ```
68
-
69
- The host provides `read_input`, `write_output` and `fetch_tile` (the last one may simply answer
70
- "no tile"); everything else is numeric exports and integer handles.
71
- [`docs/wasm-wasi-abi.md`](docs/wasm-wasi-abi.md) is the full contract, and
72
- [`tools/wasi`](tools/wasi/README.md) is a working host that CI runs on every pull request.
54
+ Four doors reach the same engine, and a capability is available from all of them unless noted:
73
55
 
74
- ### Migrating from gpx2web
56
+ | Capability | CLI | Kotlin / Java | JavaScript / TS | WASI |
57
+ |---|---|---|---|---|
58
+ | Run the pipeline | `enhance` | `Enhancer.enhanceCourseDefault` | `enhance` | `vcEnhance` |
59
+ | Configure rider, bike, wind, power | `--cyclist-*`, `--bike-*`, `--wind-*` | `CoursePhysics(Course(…))` | `enhanceWithCourse` | `vcEnhanceWithCourse` |
60
+ | Power models `constant` · `durability` · `critical-power` · `from_data` | `--cyclist-model` | `CyclistPowerSpec` | `power.type` | `power.type` |
61
+ | Terrain pacing, power slew limit | `--cyclist-pacing`, `--cyclist-slew` | provider decorators | `power.pacing`, `power.maxSlewWPerS` | idem |
62
+ | Road condition, dry or wet | `--road-condition` | `Cyclist.withRoadCondition` | `cyclist.roadCondition` | idem |
63
+ | Pedal-strike clearance | `--bike-max-pedal-angle` | `Bike.maxPedalingLeanAngleDeg` | `bike.maxPedalingLeanAngleDeg` | idem |
64
+ | Racing line + corridor mode | `--racing-line`, `--corridor` | `RacingLineOptions` | `racingLineEnabled` | idem |
65
+ | DEM elevation correction | `--fix-elevation` | `ElevationProvider` | `fixElevation: true` | host serves tiles |
66
+ | Climb detection | — | `ClimbDetector.detect` | `detectClimbs` | `vcDetectClimbsJson` |
67
+ | Racing-line report | `--racing-line-report` | `RacingLine.analyze` | `analyzeRacingLine` | `vcAnalyzeRacingLineJson` |
68
+ | Write GPX / CSV / JSON | `--gpx --csv --json` | `GpxWriter`, `CsvWriter`, `JsonWriter` | `writeGpx`, `pathToCsv`, `pathToJson` | `vcWriteGpx`, `vcPathToCsv/Json` |
69
+ | Write FIT course | `--fit` | `Path.toFitBytes` | `pathToFit`, `pathsToFit` | `vcPathToFit`, `vcPathsToFit` |
70
+ | Static map PNG | `export --map` | `MapFactoriesJvm` | — | — |
75
71
 
76
- vcyclist replaces the `gpx` and `gpxtools-cli` modules of
77
- [gpx2web](https://github.com/glandais/gpx2web).
78
- [`docs/gpx2web-coverage.md`](docs/gpx2web-coverage.md) has one row per Java class — ported,
79
- replaced, or not ported with the reason — plus the deliberate behavioural differences. For the
80
- command-line options specifically, see [`cli/README.md`](cli/README.md).
72
+ Static maps are JVM-only by construction — `:map` draws on `java.awt`.
73
+ [`docs/ledgers/surface-coverage.md`](docs/ledgers/surface-coverage.md) tracks this matrix as
74
+ capabilities land, so that a feature cannot reach one door and quietly miss the others.
81
75
 
82
76
  ## Install
83
77
 
84
- ### npm (Kotlin/JS consumers)
78
+ ### npm — browser, Node.js and Bun
85
79
 
86
80
  ```bash
87
- npm install @glandais/vcyclist-engine # Kotlin/JS bundle
88
- npm install @glandais/vcyclist-elevation # Kotlin/JS bundle
81
+ npm install @glandais/vcyclist-engine # physics, GPX, FIT, CSV/JSON, climbs, racing line
82
+ npm install @glandais/vcyclist-elevation # DEM lookups on their own
89
83
  ```
90
84
 
91
- ### Gradle / Maven (JVM or KMP consumers)
85
+ The engine bundle already carries the elevation façade; install the second package only if you
86
+ want DEM lookups without the physics.
87
+
88
+ ### Gradle — JVM or Kotlin Multiplatform
92
89
 
93
90
  ```kotlin
94
- // Gradle Kotlin DSL
95
91
  dependencies {
96
- implementation("io.github.glandais:vcyclist-engine:1.0.0") // pulls -jvm / -js per target
97
- implementation("io.github.glandais:vcyclist-elevation:1.0.0")
92
+ implementation("io.github.glandais:vcyclist-engine:4.2.1") // pulls -jvm / -js per target
93
+ implementation("io.github.glandais:vcyclist-elevation:4.2.1")
98
94
  }
99
95
  ```
100
96
 
101
- Replace `1.0.0` by the latest version shown in the badges above. KMP consumers automatically
102
- get the platform-specific variant (`-jvm`, `-js`) for their target.
103
- `vcyclist-gpx` (the `Path` model + GPX I/O) comes in transitively via `vcyclist-engine`, and
104
- can also be depended on alone if you only need parsing and resampling.
97
+ ### Maven
105
98
 
106
- See [`docs/publishing.md`](docs/publishing.md) for the release process.
99
+ ```xml
100
+ <dependency>
101
+ <groupId>io.github.glandais</groupId>
102
+ <artifactId>vcyclist-engine-jvm</artifactId>
103
+ <version>4.2.1</version>
104
+ </dependency>
105
+ ```
107
106
 
108
- ## Quick start
107
+ The badges above are the source of truth for the current version. KMP consumers get the
108
+ platform-specific variant automatically; from plain Maven, name the `-jvm` artifact yourself.
109
109
 
110
- ### Run the CLI
110
+ `vcyclist-gpx` (the `Path` model + GPX I/O) comes in transitively via `vcyclist-engine`, and can be
111
+ depended on alone if you only need parsing and resampling. `vcyclist-fit` and `vcyclist-map` are
112
+ published separately.
111
113
 
112
- The CLI is **not** on Maven Central — it is an application, distributed as a self-contained jar
113
- attached to each [GitHub release](https://github.com/glandais/vcyclist/releases). Download
114
- `vcyclist-cli-<version>-all.jar` and run it with Java 21+, or build it yourself:
114
+ ### CLI and `.wasm`
115
115
 
116
- ```bash
117
- # Build a self-contained jar, then run it from anywhere:
118
- ./gradlew :cli:executableJar
119
- java -jar cli/build/libs/vcyclist-cli-*-all.jar enhance route.gpx --gpx out.gpx --csv out.csv
116
+ The CLI is an application, not a library, so it is **not** on Maven Central: download
117
+ `vcyclist-cli-<version>-all.jar` from a [GitHub release](https://github.com/glandais/vcyclist/releases).
118
+ The WASI module is built from source (below) rather than published to a registry.
120
119
 
121
- # Or, during development:
122
- ./gradlew :cli:run -Pargs="enhance route.gpx --gpx /tmp/out.gpx"
123
- ```
120
+ **Requirements**: Java 21+ for the JVM and CLI; Node ≥ 18 (22+ recommended) or Bun for JavaScript;
121
+ a WASI runtime with the `function-references`, `gc` and `exceptions` proposals — wasmtime 46+ is
122
+ known good.
124
123
 
125
- `enhance` runs the physics pipeline; `export` produces maps, FIT, CSV and JSON from a file you
126
- already have. Elevation correction is off unless you pass `--fix-elevation`, so nothing touches
127
- the network by default. Full usage, exit codes and the migration table from gpx2web's
128
- `gpxtools-cli` are in [`cli/README.md`](cli/README.md).
124
+ ## Quick start
129
125
 
130
- ### Try the browser demo (elevation only)
126
+ ### Command line
131
127
 
132
128
  ```bash
133
- # Kotlin/JS demo
134
- ./gradlew :elevation:jsBrowserDevelopmentRun
129
+ java -jar vcyclist-cli-*-all.jar enhance route.gpx --gpx out.gpx --csv out.csv
135
130
  ```
136
131
 
137
- The demo shares the [original TS demo](https://github.com/glandais/elevation) UI (Leaflet +
138
- Chart.js + GPX upload). See [`elevation/README.md`](elevation/README.md) for details.
132
+ `enhance` runs the physics pipeline; `export` produces maps, FIT, CSV and JSON from a file you
133
+ already have. Elevation correction is off unless you pass `--fix-elevation`, so nothing touches the
134
+ network by default.
135
+
136
+ Full option reference, the rider models and what each is measured to be worth, and exit codes:
137
+ [`cli/README.md`](cli/README.md).
139
138
 
140
- ### Use from Kotlin
139
+ ### Kotlin
141
140
 
142
141
  ```kotlin
143
142
  import io.github.glandais.engine.Enhancer
144
143
  import io.github.glandais.engine.gpx.GpxParser
144
+ import io.github.glandais.engine.gpx.GpxWriter
145
145
  import io.github.glandais.engine.gpx.firstTrackAsPath
146
+ import io.github.glandais.engine.gpx.toGpxDocument
146
147
 
147
148
  suspend fun virtualize(xml: String): String {
148
149
  val path = GpxParser.parse(xml).firstTrackAsPath()
149
- val out = Enhancer.enhanceCourseDefault(path) // pure physics, no HTTP
150
- return io.github.glandais.engine.gpx.GpxWriter.write(
151
- out.toGpxDocument(trackName = "virtualized")
152
- )
150
+ val out = Enhancer.enhanceCourseDefault(path) // pure physics, no HTTP
151
+ return GpxWriter.write(out.toGpxDocument(trackName = "virtualized"))
153
152
  }
154
153
  ```
155
154
 
156
- ### Use from Java
155
+ Pass an `ElevationProvider` as the second argument to correct elevations from DEM tiles, and an
156
+ `EnhanceOptions` as the third to configure the pipeline. For a configured rider, build a
157
+ `CoursePhysics(Course(path, cyclist, bike), …)` and call `Enhancer.enhanceCourse`.
157
158
 
158
- The library is Kotlin-first, so its asynchronous entry points — elevation lookups and the
159
- `Enhancer` pipeline that may call them — are `suspend` functions. From Java that would mean
160
- hand-writing a `Continuation`, so each of them has a JVM bridge in two shapes:
161
-
162
- | Shape | Suffix | Returns | For |
163
- |---|---|---|---|
164
- | Blocking | `…Blocking` | the value | batch jobs, CLIs, tests |
165
- | Asynchronous | `…Async` | `CompletableFuture<T>` | servers, UIs |
159
+ ### Java
166
160
 
167
161
  ```java
168
- import io.github.glandais.elevation.ElevationProvider;
169
- import io.github.glandais.elevation.ElevationProviderJvm;
170
- import io.github.glandais.engine.EnhancerJvm;
171
- import io.github.glandais.engine.gpx.GpxParserJvm;
172
- import io.github.glandais.engine.gpx.GpxToPathKt;
173
- import io.github.glandais.engine.gpx.GpxWriterJvm;
174
- import io.github.glandais.engine.path.Path;
175
-
176
- String xml = Files.readString(java.nio.file.Path.of("route.gpx"));
177
- Path input = GpxToPathKt.firstTrackAsPath(GpxParserJvm.parse(xml));
178
-
179
- // Physics only, nothing touches the network:
162
+ Path input = GpxToPathJvm.firstTrackAsPath(GpxParserJvm.parse(xml));
180
163
  Path enhanced = EnhancerJvm.enhanceCourseDefaultBlocking(input);
181
-
182
- // Or with elevation correction, off the calling thread:
183
- ElevationProvider provider = ElevationProviderJvm.newElevationProvider();
184
- CompletableFuture<Path> future = EnhancerJvm.enhanceCourseDefaultAsync(input, provider);
185
-
186
164
  String out = GpxWriterJvm.write(enhanced);
187
165
  ```
188
166
 
189
- **Every entry point has a `…Jvm` twin**, in the same package as the Kotlin original:
190
- `GpxParserJvm`, `GpxWriterJvm`, `GpxFromPathJvm`, `GpxModelJvm`, `PathSimplifierJvm`,
191
- `ElevationStepJvm`, `TabularWritersJvm` (`:gpx`), `ElevationProviderJvm`, `TileFetcherJvm`
192
- (`:elevation`), `EnhancerJvm`, `EngineModelJvm`, `ClimbDetectorJvm` (`:engine`), `PathToFitJvm`
193
- (`:fit`), `MapFactoriesJvm` (`:map`). They exist for two reasons: Kotlin default arguments are
194
- invisible to Java, and `@JvmOverloads` cannot be used on the common source sets where the API
195
- lives. Call them and every optional parameter becomes optional again.
196
-
197
- - `…Blocking` parks the calling thread. **Never call it from a UI thread, from inside a
198
- coroutine, or from a thread of the executor you passed** — the first two freeze the caller,
199
- the third can deadlock the pool. Exceptions propagate unchanged.
200
- - `…Async` takes an optional `java.util.concurrent.Executor` (default: the coroutines IO
201
- dispatcher, the right pool for network-bound work). Cancelling the returned future cancels the
202
- work underneath; failures arrive as a `CompletionException`, so unwrap with `getCause()`.
203
- - Everything else in the library is already synchronous and needs no bridge: `GpxParser`,
204
- `GpxWriter`, `ElevationStep.smoothElevation`, `PathSimplifier`, the resamplers, FIT and the
205
- CSV / JSON writers.
206
- - `ElevationProviderJvm.newElevationProvider(config, fetcher)` takes a
207
- `Function<String, RawTile>`, so a disk cache — or any other tile transport — plugs in from Java.
208
- The fetcher may block (it runs on the IO dispatcher) but must be thread-safe: up to ten tiles
209
- are fetched at once. See [`elevation/README.md`](elevation/README.md) for a complete example.
210
-
211
- ### Use from JavaScript / TypeScript
212
-
213
- `generateTypeScriptDefinitions()` is enabled on `js(IR)`, so you get a `.d.ts` next to the
214
- bundle in `build/dist/js/productionExecutable/vcyclist-engine.d.{ts,mts}`.
215
-
216
- The Kotlin/JS variant (`@glandais/vcyclist-engine`, `@glandais/vcyclist-elevation`) runs
217
- **in both browser and Node.js / Bun**.
218
-
219
- #### Getting at the exports
220
-
221
- Kotlin/JS emits a **UMD bundle that preserves the package namespace**, so the module has
222
- exactly one top-level export, `io`. Named imports do not work — `import { parseGpx } from
223
- '@glandais/vcyclist-engine'` fails under Node ESM with *"Named export 'parseGpx' not found"*.
224
- Unwrap the namespace once and destructure from it :
225
-
226
- ```js
227
- import * as engineRaw from '@glandais/vcyclist-engine'; // or: require('@glandais/vcyclist-engine')
228
-
229
- const engine = engineRaw.io.github.glandais.engine;
230
- ```
167
+ Every entry point has a `…Jvm` twin that restores Kotlin's default arguments, and every `suspend`
168
+ function has both a `…Blocking` and a `…Async` (`CompletableFuture`) bridge.
169
+ **[`docs/guides/using-from-java.md`](docs/guides/using-from-java.md)** has the calling rules — some
170
+ of them matter, `…Blocking` on a UI thread being the obvious one.
231
171
 
232
- Every snippet below assumes that `engine`. `demo/src/engine-shim.ts` does the same unwrap and
233
- adds TypeScript types for the DTOs, which Kotlin/JS emits as referenced names only.
172
+ ### JavaScript / TypeScript
234
173
 
235
- #### Browser
174
+ Kotlin/JS emits a UMD bundle that preserves the package namespace, so there is exactly one
175
+ top-level export. Named imports do not work — unwrap it once:
236
176
 
237
177
  ```js
238
- const { parseGpx, enhance, writeGpx, pathSize, pathTotalDistance } = engine;
239
-
240
- const handle = parseGpx(gpxXml);
241
- console.log('input points:', pathSize(handle));
242
- const out = await enhance(handle, null); // physics only, no HTTP
243
- console.log('output:', pathSize(out), pathTotalDistance(out), 'm');
244
- const xml = writeGpx(out);
245
- ```
246
-
247
- #### Node.js / Bun (with elevation correction)
248
-
249
- ```js
250
- const { parseGpx, enhance, writeGpx } = engine;
251
-
252
- const handle = parseGpx(gpxXml);
253
- const out = await enhance(handle, { fixElevation: true }); // fetches DEM tiles, decodes WebP
254
- const xml = writeGpx(out);
255
- ```
256
-
257
- `enhance(..., { fixElevation: true })` auto-instantiates a default `ElevationProvider`
258
- (mapterhorn Terrarium tiles) and runs the full pipeline (densify → fix elevation → smooth →
259
- max speeds → virtualize → resample → simplify).
178
+ import * as engineRaw from '@glandais/vcyclist-engine';
179
+ const engine = engineRaw.io.github.glandais.engine;
260
180
 
261
- #### FIT export
181
+ const { parseGpx, enhance, writeGpx, pathSize, pathTotalDistance } = engine;
262
182
 
263
- ```js
264
- const { parseGpx, enhance, pathToFit } = engine;
183
+ const path = parseGpx(gpxXml);
184
+ const out = await enhance(path, null); // physics only; { fixElevation: true } for DEM
185
+ console.log(pathSize(out), pathTotalDistance(out), 'm');
265
186
 
266
- const out = await enhance(parseGpx(gpxXml), null);
267
- const fit = pathToFit(out, 'My route', Date.parse('2026-08-01T08:00:00Z'));
268
- // Kotlin/JS returns an Int8Array containing the FIT file.
187
+ // `<power>` carries the SOURCE file's power by default — writing simulated data into a format the
188
+ // ecosystem reads as a recording is the caller's call. Ask for the simulation explicitly:
189
+ const xml = writeGpx(out, true, 'computed-or-input', 'my route');
269
190
  ```
270
191
 
271
- FIT has no relative clock, so the start instant is mandatory. The output is a **Course** file
272
- (a route to follow), which is what a virtualized trace should be — not an Activity.
273
-
274
- #### Multi-track GPX
192
+ **[`docs/guides/using-from-javascript.md`](docs/guides/using-from-javascript.md)** covers the whole
193
+ façade — `enhanceWithCourse` and its five DTOs, FIT and CSV/JSON export, climbs, the racing-line
194
+ report, the standalone elevation API, and the Node/Bun specifics.
275
195
 
276
- `parseGpx` returns the **first** track, which is what most files contain. For documents with
277
- several `<trk>` or several `<trkseg>` :
196
+ ### WASI — no JVM, no JavaScript
278
197
 
279
- ```js
280
- const { parseGpxTracks, parseGpxSegments, writeGpxTracks } = engine;
198
+ `:engine` links a standalone WASI module, so the whole pipeline runs inside wasmtime, WasmEdge,
199
+ wazero, or an embedding in Go, Rust, Python or the JVM.
281
200
 
282
- const tracks = parseGpxTracks(gpxXml); // one path per <trk> *and* per <rte>
283
- const segments = parseGpxSegments(gpxXml); // one path per <trkseg>, always continuous
284
- const xml = writeGpxTracks(tracks); // one <trk> per path
201
+ ```bash
202
+ ./gradlew :engine:wasmModule # -> engine/build/wasm/vcyclist-engine.wasm + .sha256
285
203
  ```
286
204
 
287
- Concatenating segments folds the pause between them into `totalDistance` — a `<trkseg>`
288
- boundary is a physical discontinuity. Use `parseGpxSegments` when that artefact matters.
289
-
290
- `parseGpxTracks` also returns `<rte>` routes, which many planners emit and which used to be
291
- dropped silently. `parseGpxTracksOnly` and `parseGpxRoutesOnly` select one container or the other.
205
+ The host implements three imports — `read_input`, `write_output` and `fetch_tile` (which may simply
206
+ answer "no tile") — and everything else is numeric exports over integer handles:
292
207
 
293
- #### Options on the writers
208
+ ```python
209
+ staged["bytes"] = open("ride.gpx", "rb").read()
210
+ handle = exports["vcParseGpx"](store, len(staged["bytes"]))
294
211
 
295
- ```js
296
- const bare = engine.writeGpx(path, false); // no <extensions>: no power, heart rate, cadence
297
- const fit = engine.pathsToFit([a, b], 'Two days', Date.parse('2026-08-01T08:00:00Z'));
212
+ staged["bytes"] = b'{"computeOnePointPerSecond": true}'
213
+ out = exports["vcEnhance"](store, handle, len(staged["bytes"]))
214
+ print(exports["vcPathDurationMs"](store, out) / 1000, "s")
298
215
  ```
299
216
 
300
- `writeGpx`, `writeGpxAt` and `writeGpxTracks` take a trailing `writeExtensions` flag (default
301
- `true`). `pathsToFit` encodes several paths into one FIT course — a lap and a timer event pair
302
- each — where `pathToFit` takes a single one.
217
+ This is not a reduced surface: `vcEnhanceWithCourse`, `vcPathToFit`, `vcPathToCsv`,
218
+ `vcDetectClimbsJson` and `vcAnalyzeRacingLineJson` are all there, and §10 of the guide is a
219
+ function-by-function parity table against the JavaScript façade.
220
+ **[`docs/guides/wasm-wasi-abi.md`](docs/guides/wasm-wasi-abi.md)** is the full contract;
221
+ [`tools/wasi`](tools/wasi/README.md) is a working host that CI runs on every pull request.
303
222
 
304
- On Node.js / Bun, tile decoding uses [`@jsquash/webp`](https://www.npmjs.com/package/@jsquash/webp)
305
- (a pure-WASM WebP decoder, ~50 KB, listed as a runtime `dependency` of
306
- `@glandais/vcyclist-engine` and `@glandais/vcyclist-elevation`). It is loaded lazily via
307
- `eval('require')`, so browser bundlers do not pull it into the browser build. Requires
308
- Node ≥ 18 (`globalThis.fetch` is built-in since Node 18 / Bun) ; Node 22+ recommended for
309
- ESM `require()` support.
223
+ ## Try the demo
310
224
 
311
- ## Try the interactive demo
225
+ **<https://glandais.github.io/vcyclist>** — no install, runs the real engine in your browser.
312
226
 
313
- The [`demo/`](demo/) module is a Vue 3 + Vite frontend that exercises the
314
- Kotlin/JS engine end-to-end in a browser (GPX upload, configurable cyclist /
315
- bike / wind / power, chart + map, hover sync).
227
+ A Vue 3 + Leaflet + Chart.js app with two routes, both on the same Kotlin/JS bundle:
316
228
 
317
- ```bash
318
- ./gradlew :demo:assemble
319
- python -m http.server -d demo/dist 8000 # or any static file server
320
- ```
321
-
322
- See [`demo/README.md`](demo/README.md) for the dev workflow and architecture.
323
-
324
- ## Build & test
229
+ - `#/` — **GPX analysis**: upload a route, run the physics pipeline, inspect every field on a
230
+ synchronized chart and map, with climb detection and the racing line, then download the result
231
+ as GPX or as a Garmin FIT course.
232
+ - `#/elevation` — **elevation explorer**: query DEM tiles at a point or along a path, with
233
+ smoothing, Douglas-Peucker simplification and hillshade/slope relief.
325
234
 
326
235
  ```bash
327
- ./gradlew check # full build + all tests on all targets
328
- ./gradlew :engine:allTests # engine tests across JVM / JS Node / JS browser
329
- ./gradlew :elevation:allTests # elevation tests
330
- ./gradlew :elevation:jvmTest --tests '*Integration*' \
331
- -PINTEGRATION=1 # live HTTP tests against tiles.mapterhorn.com
332
- ./gradlew ktlintCheck # lint
333
- ```
334
-
335
- ## Layout
336
-
236
+ cd demo && npm run dev # http://localhost:3000, against a locally built engine
337
237
  ```
338
- vcyclist/
339
- ├── settings.gradle.kts # multi-module Gradle KMP project
340
- ├── gradle/libs.versions.toml # version catalog (Kotlin 2.3.21, coroutines 1.11, xmlutil 0.91, …)
341
- ├── docs/
342
- │ ├── PLAN.md # task-by-task progress (Phases 1-2bis)
343
- │ ├── elevation-integration.md # how to run live HTTP integration tests
344
- │ ├── kotlin-js-jvm-webp.md # Kotlin/JS ↔ JS interop guide
345
- │ └── tasks/ # one Markdown per implementation task (00-31, + bonus demos)
346
- ├── elevation/ # :elevation KMP module
347
- ├── engine/ # :engine KMP module (depends on :elevation)
348
- └── codegen/ # :codegen JVM helper for Path accessor generation
349
- ```
350
-
351
- ## Status
352
238
 
353
- - ✅ **Phase 1** — `:elevation` module port (tasks 00-09) : Terrarium tiles, Haversine, ECEF,
354
- Douglas-Peucker 3D, smoother, LRU cache + TileManager, `ElevationProvider`, live HTTP integration.
355
- - ✅ **Phase 2** — `:engine` module port (tasks 10-28) : Path model, Cyclist/Bike/Course,
356
- GPX I/O, full physics, simulation, post-processing, `Enhancer`, CLI, `@JsExport` façades.
357
- - ✅ **Phase 2bis** — pipeline fidelity fixes (tasks 29-31) : `VirtualizeService` last-point
358
- timestamp, `PointPerDistance` port, integration into `Enhancer`.
359
- - ✅ **Phase 3** — Node.js / Bun support (tasks 32-33) : runtime-detection in
360
- `TileFetcher.js.kt` (browser path unchanged, Node path uses `globalThis.fetch` +
361
- `@jsquash/webp` WASM decoder loaded via lazy `eval('require')`), webpack externals to keep
362
- the browser bundle free of `@jsquash/webp`, `ElevationProvider` auto-instantiation in
363
- `EngineJsApi.enhance` when `opts.fixElevation` is true (JS façade), 6 jsTest classes
364
- gated by `INTEGRATION=1`.
365
-
366
- Total `:engine` test coverage : 32 test classes / ~326 commonTest cases / 3 targets =
367
- ~1000 green executions, plus JVM-only smoke tests for the CLI and the full pipeline.
368
-
369
- End-to-end smoke (after Phase 2bis) : sample.gpx (3569 source points, 130 km, ~4550 m gain)
370
- runs through the complete `Enhancer` pipeline in ~1.7 s on JVM, producing ~1000 simplified
371
- output points covering ~128.6 km / ~5.3 h of simulated ride.
239
+ See [`demo/README.md`](demo/README.md) for the architecture and the static-site build.
372
240
 
373
241
  ## Documentation
374
242
 
375
- - [`docs/PLAN.md`](docs/PLAN.md) — task-by-task plan with commit hashes for every step.
376
- - [`docs/tasks/`](docs/tasks/) — detailed Markdown spec for each task (00-31 + bonus demos).
377
- - [`docs/kotlin-js-jvm-webp.md`](docs/kotlin-js-jvm-webp.md) — Kotlin/JS ↔ JS interop
378
- guide that underpins the `@JsExport` façades and the WebP tile decoding.
379
- - [`docs/publishing.md`](docs/publishing.md) — release flow (Maven Central + npm via
380
- semantic-release on push to `develop`).
381
- - [`elevation/README.md`](elevation/README.md) — `:elevation` module details + browser demos.
243
+ [`docs/README.md`](docs/README.md) is the index and says which documents are current and which are
244
+ frozen history. The short version:
245
+
246
+ - [`docs/guides/`](docs/README.md#guides) — how to use and extend the project: Java, JavaScript, the
247
+ WASI ABI, the racing line, the release flow
248
+ - [`docs/ledgers/`](docs/README.md#ledgers) — living state: research improvements, build warnings,
249
+ surface coverage
250
+ - [`docs/research/`](docs/research/README.md) — the solo-rider simulation research report
251
+ - Module documentation lives next to its module: [`cli/`](cli/README.md),
252
+ [`elevation/`](elevation/README.md), [`map/`](map/README.md), [`demo/`](demo/README.md)
382
253
 
383
254
  ## Contributing
384
255
 
385
- `develop` is the **default and only long-lived branch** — there is no `main`. Open PRs
386
- against `develop` using [Conventional Commits](https://www.conventionalcommits.org/) :
387
- `feat:` triggers a minor release, `fix:` a patch, anything else is a no-op release-wise.
388
- Every push to `develop` runs the full multi-target test suite via
389
- `.github/workflows/release.yml` and, if green, lets semantic-release tag a new version,
390
- publish to Maven Central + npm, and commit the version bump back to `develop` with
391
- `[skip ci]`. See [`docs/publishing.md`](docs/publishing.md) for the full flow.
256
+ Open PRs against `develop` — the default and only long-lived branch — using
257
+ [Conventional Commits](https://www.conventionalcommits.org/). Build commands, module layout,
258
+ testing conventions and the release flow are in [`CONTRIBUTING.md`](CONTRIBUTING.md).
392
259
 
393
260
  ## License
394
261
 
395
- Apache License 2.0, aligned with the upstream `gpx2web` project. See the Maven Central POM
396
- metadata in `engine/build.gradle.kts` and `elevation/build.gradle.kts`. A top-level `LICENSE`
397
- file will be added before the first public release.
262
+ [Apache License 2.0](LICENSE).
package/index.cjs ADDED
@@ -0,0 +1,70 @@
1
+ // GENERATED by `./gradlew :codegen:generateTsFacade` — do not edit.
2
+ //
3
+ // The CommonJS half of the same unwrapping. Both spellings ship because the bundle is UMD
4
+ // and Node resolves a bare `.js` as CommonJS — an ESM-only wrapper breaks `require()`.
5
+ const root = require('./vcyclist-engine.js');
6
+
7
+ const ns = root.io?.github?.glandais?.engine ?? root;
8
+
9
+ const climbDefaults = Object.freeze({
10
+ minMinClimbElevationM: 10,
11
+ maxMinClimbElevationM: 35,
12
+ minClimbElevationRatio: 100,
13
+ minGradePercent: 3,
14
+ maxDiffRealGrade: 1.3,
15
+ booster: 1.3,
16
+ maxAnalysisPoints: 3000,
17
+ });
18
+
19
+ const csvDefaults = Object.freeze({
20
+ separator: ',',
21
+ unitsInHeader: true,
22
+ decimals: null,
23
+ lineSeparator: '\n',
24
+ });
25
+
26
+ const jsonDefaults = Object.freeze({
27
+ pretty: false,
28
+ decimals: null,
29
+ includeMeta: true,
30
+ });
31
+
32
+ module.exports = {
33
+ parseGpxWaypoints: ns.parseGpxWaypoints,
34
+ parseGpx: ns.parseGpx,
35
+ parseGpxTracks: ns.parseGpxTracks,
36
+ parseGpxSegments: ns.parseGpxSegments,
37
+ parseGpxTracksOnly: ns.parseGpxTracksOnly,
38
+ parseGpxRoutesOnly: ns.parseGpxRoutesOnly,
39
+ writeGpxTracks: ns.writeGpxTracks,
40
+ pathSize: ns.pathSize,
41
+ pathTotalDistance: ns.pathTotalDistance,
42
+ pathDurationMs: ns.pathDurationMs,
43
+ pathElevationGain: ns.pathElevationGain,
44
+ pathElevationLoss: ns.pathElevationLoss,
45
+ pathElevationGainFiltered: ns.pathElevationGainFiltered,
46
+ pathElevationLossFiltered: ns.pathElevationLossFiltered,
47
+ pathReportedElevationGain: ns.pathReportedElevationGain,
48
+ pathReportedElevationLoss: ns.pathReportedElevationLoss,
49
+ pointAt: ns.pointAt,
50
+ writeGpx: ns.writeGpx,
51
+ writeGpxAt: ns.writeGpxAt,
52
+ enhance: ns.enhance,
53
+ enhanceWithCourse: ns.enhanceWithCourse,
54
+ getField: ns.getField,
55
+ fieldDefinitions: ns.fieldDefinitions,
56
+ pathLatitudeDeg: ns.pathLatitudeDeg,
57
+ pathLongitudeDeg: ns.pathLongitudeDeg,
58
+ pathToCsv: ns.pathToCsv,
59
+ pathToJson: ns.pathToJson,
60
+ pathToFit: ns.pathToFit,
61
+ pathsToFit: ns.pathsToFit,
62
+ dominantHeadwindAzimuth: ns.dominantHeadwindAzimuth,
63
+ dominantHeadwindAzimuthOfTracks: ns.dominantHeadwindAzimuthOfTracks,
64
+ detectClimbs: ns.detectClimbs,
65
+ detectClimbsWithOptions: ns.detectClimbsWithOptions,
66
+ analyzeRacingLine: ns.analyzeRacingLine,
67
+ climbDefaults,
68
+ csvDefaults,
69
+ jsonDefaults,
70
+ };