@glandais/vcyclist-engine 4.2.1 → 4.3.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
  ┌──────────────┐
@@ -40,358 +39,223 @@ TypeScript library for elevation data.
40
39
  (*) optional — needs an ElevationProvider
41
40
  ```
42
41
 
43
- ## Modules
42
+ ## What it can do
44
43
 
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 |
44
+ **In**: GPX tracks, routes (`<rte>`), segments and waypoints.
54
45
 
55
- ### Running it without a JVM or a JavaScript host
46
+ **Physics**: elevation correction from DEM tiles, curvature estimation or an optimal racing line,
47
+ cornering and braking sharing one friction-ellipse budget, a configurable rider and bike, and a
48
+ 1 Hz time-stepping simulation that produces speed, time and power at every point.
56
49
 
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.
50
+ **Out**: GPX, Garmin FIT courses, CSV, column-oriented JSON, static PNG maps — plus climb
51
+ detection, a racing-line report and worst-case wind analysis.
59
52
 
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.
53
+ Four doors reach the same engine, and a capability is available from all of them unless noted:
73
54
 
74
- ### Migrating from gpx2web
55
+ | Capability | CLI | Kotlin / Java | JavaScript / TS | WASI |
56
+ |---|---|---|---|---|
57
+ | Run the pipeline | `enhance` | `Enhancer.enhanceCourseDefault` | `enhance` | `vcEnhance` |
58
+ | Configure rider, bike, wind, power | `--cyclist-*`, `--bike-*`, `--wind-*` | `CoursePhysics(Course(…))` | `enhanceWithCourse` | `vcEnhanceWithCourse` |
59
+ | Power models `constant` · `durability` · `critical-power` · `from_data` | `--cyclist-model` | `CyclistPowerSpec` | `power.type` | `power.type` |
60
+ | Terrain pacing, power slew limit | `--cyclist-pacing`, `--cyclist-slew` | provider decorators | `power.pacing`, `power.maxSlewWPerS` | idem |
61
+ | Road condition, dry or wet | `--road-condition` | `Cyclist.withRoadCondition` | `cyclist.roadCondition` | idem |
62
+ | Pedal-strike clearance | `--bike-max-pedal-angle` | `Bike.maxPedalingLeanAngleDeg` | `bike.maxPedalingLeanAngleDeg` | idem |
63
+ | Racing line + corridor mode | `--racing-line`, `--corridor` | `RacingLineOptions` | `racingLineEnabled` | idem |
64
+ | DEM elevation correction | `--fix-elevation` | `ElevationProvider` | `fixElevation: true` | host serves tiles |
65
+ | Climb detection | — | `ClimbDetector.detect` | `detectClimbs` | `vcDetectClimbsJson` |
66
+ | Racing-line report | `--racing-line-report` | `RacingLine.analyze` | `analyzeRacingLine` | `vcAnalyzeRacingLineJson` |
67
+ | Write GPX / CSV / JSON | `--gpx --csv --json` | `GpxWriter`, `CsvWriter`, `JsonWriter` | `writeGpx`, `pathToCsv`, `pathToJson` | `vcWriteGpx`, `vcPathToCsv/Json` |
68
+ | Write FIT course | `--fit` | `Path.toFitBytes` | `pathToFit`, `pathsToFit` | `vcPathToFit`, `vcPathsToFit` |
69
+ | Static map PNG | `export --map` | `MapFactoriesJvm` | — | — |
75
70
 
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).
71
+ Static maps are JVM-only by construction — `:map` draws on `java.awt`.
72
+ [`docs/ledgers/surface-coverage.md`](docs/ledgers/surface-coverage.md) tracks this matrix as
73
+ capabilities land, so that a feature cannot reach one door and quietly miss the others.
81
74
 
82
75
  ## Install
83
76
 
84
- ### npm (Kotlin/JS consumers)
77
+ ### npm — browser, Node.js and Bun
85
78
 
86
79
  ```bash
87
- npm install @glandais/vcyclist-engine # Kotlin/JS bundle
88
- npm install @glandais/vcyclist-elevation # Kotlin/JS bundle
80
+ npm install @glandais/vcyclist-engine # physics, GPX, FIT, CSV/JSON, climbs, racing line
81
+ npm install @glandais/vcyclist-elevation # DEM lookups on their own
89
82
  ```
90
83
 
91
- ### Gradle / Maven (JVM or KMP consumers)
84
+ The engine bundle already carries the elevation façade; install the second package only if you
85
+ want DEM lookups without the physics.
86
+
87
+ ### Gradle — JVM or Kotlin Multiplatform
92
88
 
93
89
  ```kotlin
94
- // Gradle Kotlin DSL
95
90
  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")
91
+ implementation("io.github.glandais:vcyclist-engine:4.2.1") // pulls -jvm / -js per target
92
+ implementation("io.github.glandais:vcyclist-elevation:4.2.1")
98
93
  }
99
94
  ```
100
95
 
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.
96
+ ### Maven
105
97
 
106
- See [`docs/publishing.md`](docs/publishing.md) for the release process.
98
+ ```xml
99
+ <dependency>
100
+ <groupId>io.github.glandais</groupId>
101
+ <artifactId>vcyclist-engine-jvm</artifactId>
102
+ <version>4.2.1</version>
103
+ </dependency>
104
+ ```
107
105
 
108
- ## Quick start
106
+ The badges above are the source of truth for the current version. KMP consumers get the
107
+ platform-specific variant automatically; from plain Maven, name the `-jvm` artifact yourself.
109
108
 
110
- ### Run the CLI
109
+ `vcyclist-gpx` (the `Path` model + GPX I/O) comes in transitively via `vcyclist-engine`, and can be
110
+ depended on alone if you only need parsing and resampling. `vcyclist-fit` and `vcyclist-map` are
111
+ published separately.
111
112
 
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:
113
+ ### CLI and `.wasm`
115
114
 
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
115
+ The CLI is an application, not a library, so it is **not** on Maven Central: download
116
+ `vcyclist-cli-<version>-all.jar` from a [GitHub release](https://github.com/glandais/vcyclist/releases).
117
+ The WASI module is built from source (below) rather than published to a registry.
120
118
 
121
- # Or, during development:
122
- ./gradlew :cli:run -Pargs="enhance route.gpx --gpx /tmp/out.gpx"
123
- ```
119
+ **Requirements**: Java 21+ for the JVM and CLI; Node ≥ 18 (22+ recommended) or Bun for JavaScript;
120
+ a WASI runtime with the `function-references`, `gc` and `exceptions` proposals — wasmtime 46+ is
121
+ known good.
124
122
 
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).
123
+ ## Quick start
129
124
 
130
- ### Try the browser demo (elevation only)
125
+ ### Command line
131
126
 
132
127
  ```bash
133
- # Kotlin/JS demo
134
- ./gradlew :elevation:jsBrowserDevelopmentRun
128
+ java -jar vcyclist-cli-*-all.jar enhance route.gpx --gpx out.gpx --csv out.csv
135
129
  ```
136
130
 
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.
131
+ `enhance` runs the physics pipeline; `export` produces maps, FIT, CSV and JSON from a file you
132
+ already have. Elevation correction is off unless you pass `--fix-elevation`, so nothing touches the
133
+ network by default.
134
+
135
+ Full option reference, the rider models and what each is measured to be worth, and exit codes:
136
+ [`cli/README.md`](cli/README.md).
139
137
 
140
- ### Use from Kotlin
138
+ ### Kotlin
141
139
 
142
140
  ```kotlin
143
141
  import io.github.glandais.engine.Enhancer
144
142
  import io.github.glandais.engine.gpx.GpxParser
143
+ import io.github.glandais.engine.gpx.GpxWriter
145
144
  import io.github.glandais.engine.gpx.firstTrackAsPath
145
+ import io.github.glandais.engine.gpx.toGpxDocument
146
146
 
147
147
  suspend fun virtualize(xml: String): String {
148
148
  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
- )
149
+ val out = Enhancer.enhanceCourseDefault(path) // pure physics, no HTTP
150
+ return GpxWriter.write(out.toGpxDocument(trackName = "virtualized"))
153
151
  }
154
152
  ```
155
153
 
156
- ### Use from Java
154
+ Pass an `ElevationProvider` as the second argument to correct elevations from DEM tiles, and an
155
+ `EnhanceOptions` as the third to configure the pipeline. For a configured rider, build a
156
+ `CoursePhysics(Course(path, cyclist, bike), …)` and call `Enhancer.enhanceCourse`.
157
157
 
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 |
158
+ ### Java
166
159
 
167
160
  ```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:
161
+ Path input = GpxToPathJvm.firstTrackAsPath(GpxParserJvm.parse(xml));
180
162
  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
163
  String out = GpxWriterJvm.write(enhanced);
187
164
  ```
188
165
 
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
- ```
166
+ Every entry point has a `…Jvm` twin that restores Kotlin's default arguments, and every `suspend`
167
+ function has both a `…Blocking` and a `…Async` (`CompletableFuture`) bridge.
168
+ **[`docs/guides/using-from-java.md`](docs/guides/using-from-java.md)** has the calling rules — some
169
+ of them matter, `…Blocking` on a UI thread being the obvious one.
231
170
 
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.
171
+ ### JavaScript / TypeScript
234
172
 
235
- #### Browser
173
+ Kotlin/JS emits a UMD bundle that preserves the package namespace, so there is exactly one
174
+ top-level export. Named imports do not work — unwrap it once:
236
175
 
237
176
  ```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).
177
+ import * as engineRaw from '@glandais/vcyclist-engine';
178
+ const engine = engineRaw.io.github.glandais.engine;
260
179
 
261
- #### FIT export
180
+ const { parseGpx, enhance, writeGpx, pathSize, pathTotalDistance } = engine;
262
181
 
263
- ```js
264
- const { parseGpx, enhance, pathToFit } = engine;
182
+ const path = parseGpx(gpxXml);
183
+ const out = await enhance(path, null); // physics only; { fixElevation: true } for DEM
184
+ console.log(pathSize(out), pathTotalDistance(out), 'm');
265
185
 
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.
186
+ // `<power>` carries the SOURCE file's power by default — writing simulated data into a format the
187
+ // ecosystem reads as a recording is the caller's call. Ask for the simulation explicitly:
188
+ const xml = writeGpx(out, true, 'computed-or-input', 'my route');
269
189
  ```
270
190
 
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
191
+ **[`docs/guides/using-from-javascript.md`](docs/guides/using-from-javascript.md)** covers the whole
192
+ façade — `enhanceWithCourse` and its five DTOs, FIT and CSV/JSON export, climbs, the racing-line
193
+ report, the standalone elevation API, and the Node/Bun specifics.
275
194
 
276
- `parseGpx` returns the **first** track, which is what most files contain. For documents with
277
- several `<trk>` or several `<trkseg>` :
195
+ ### WASI — no JVM, no JavaScript
278
196
 
279
- ```js
280
- const { parseGpxTracks, parseGpxSegments, writeGpxTracks } = engine;
197
+ `:engine` links a standalone WASI module, so the whole pipeline runs inside wasmtime, WasmEdge,
198
+ wazero, or an embedding in Go, Rust, Python or the JVM.
281
199
 
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
200
+ ```bash
201
+ ./gradlew :engine:wasmModule # -> engine/build/wasm/vcyclist-engine.wasm + .sha256
285
202
  ```
286
203
 
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.
204
+ The host implements three imports — `read_input`, `write_output` and `fetch_tile` (which may simply
205
+ answer "no tile") — and everything else is numeric exports over integer handles:
292
206
 
293
- #### Options on the writers
207
+ ```python
208
+ staged["bytes"] = open("ride.gpx", "rb").read()
209
+ handle = exports["vcParseGpx"](store, len(staged["bytes"]))
294
210
 
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'));
211
+ staged["bytes"] = b'{"computeOnePointPerSecond": true}'
212
+ out = exports["vcEnhance"](store, handle, len(staged["bytes"]))
213
+ print(exports["vcPathDurationMs"](store, out) / 1000, "s")
298
214
  ```
299
215
 
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.
216
+ This is not a reduced surface: `vcEnhanceWithCourse`, `vcPathToFit`, `vcPathToCsv`,
217
+ `vcDetectClimbsJson` and `vcAnalyzeRacingLineJson` are all there, and §10 of the guide is a
218
+ function-by-function parity table against the JavaScript façade.
219
+ **[`docs/guides/wasm-wasi-abi.md`](docs/guides/wasm-wasi-abi.md)** is the full contract;
220
+ [`tools/wasi`](tools/wasi/README.md) is a working host that CI runs on every pull request.
303
221
 
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.
222
+ ## Try the demo
310
223
 
311
- ## Try the interactive demo
224
+ **<https://glandais.github.io/vcyclist>** — no install, runs the real engine in your browser.
312
225
 
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).
226
+ A Vue 3 + Leaflet + Chart.js app with two routes, both on the same Kotlin/JS bundle:
316
227
 
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
228
+ - `#/` — **GPX analysis**: upload a route, run the physics pipeline, inspect every field on a
229
+ synchronized chart and map, with climb detection and the racing line, then download the result
230
+ as GPX or as a Garmin FIT course.
231
+ - `#/elevation` — **elevation explorer**: query DEM tiles at a point or along a path, with
232
+ smoothing, Douglas-Peucker simplification and hillshade/slope relief.
325
233
 
326
234
  ```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
-
235
+ cd demo && npm run dev # http://localhost:3000, against a locally built engine
337
236
  ```
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
237
 
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.
238
+ See [`demo/README.md`](demo/README.md) for the architecture and the static-site build.
372
239
 
373
240
  ## Documentation
374
241
 
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.
242
+ [`docs/README.md`](docs/README.md) is the index and says which documents are current and which are
243
+ frozen history. The short version:
244
+
245
+ - [`docs/guides/`](docs/README.md#guides) — how to use and extend the project: Java, JavaScript, the
246
+ WASI ABI, the racing line, the release flow
247
+ - [`docs/ledgers/`](docs/README.md#ledgers) — living state: research improvements, build warnings,
248
+ surface coverage
249
+ - [`docs/research/`](docs/research/README.md) — the solo-rider simulation research report
250
+ - Module documentation lives next to its module: [`cli/`](cli/README.md),
251
+ [`elevation/`](elevation/README.md), [`map/`](map/README.md), [`demo/`](demo/README.md)
382
252
 
383
253
  ## Contributing
384
254
 
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.
255
+ Open PRs against `develop` — the default and only long-lived branch — using
256
+ [Conventional Commits](https://www.conventionalcommits.org/). Build commands, module layout,
257
+ testing conventions and the release flow are in [`CONTRIBUTING.md`](CONTRIBUTING.md).
392
258
 
393
259
  ## License
394
260
 
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.
261
+ [Apache License 2.0](LICENSE).
@@ -153,23 +153,23 @@ if (typeof String.prototype.startsWith === 'undefined') {
153
153
  initMetadataForClass(AbstractMutableCollection, 'AbstractMutableCollection', VOID, AbstractCollection, [Collection, MutableIterable]);
154
154
  initMetadataForClass(IteratorImpl, 'IteratorImpl');
155
155
  initMetadataForClass(ListIteratorImpl, 'ListIteratorImpl', VOID, IteratorImpl);
156
- initMetadataForClass(AbstractMutableList, 'AbstractMutableList', VOID, AbstractMutableCollection, [KtList, Collection, MutableIterable]);
156
+ initMetadataForClass(AbstractMutableList, 'AbstractMutableList', VOID, AbstractMutableCollection, [KtList, MutableIterable, Collection]);
157
157
  initMetadataForClass(AbstractMap, 'AbstractMap', VOID, VOID, [KtMap]);
158
158
  initMetadataForClass(AbstractMutableMap, 'AbstractMutableMap', VOID, AbstractMap, [KtMap]);
159
- initMetadataForClass(AbstractMutableSet, 'AbstractMutableSet', VOID, AbstractMutableCollection, [Collection, MutableIterable, KtSet]);
159
+ initMetadataForClass(AbstractMutableSet, 'AbstractMutableSet', VOID, AbstractMutableCollection, [KtSet, MutableIterable, Collection]);
160
160
  initMetadataForCompanion(Companion_2);
161
161
  initMetadataForInterface(RandomAccess, 'RandomAccess');
162
- initMetadataForClass(ArrayList, 'ArrayList', ArrayList_init_$Create$, AbstractMutableList, [KtList, Collection, MutableIterable, RandomAccess]);
162
+ initMetadataForClass(ArrayList, 'ArrayList', ArrayList_init_$Create$, AbstractMutableList, [KtList, MutableIterable, Collection, RandomAccess]);
163
163
  initMetadataForClass(HashMap, 'HashMap', HashMap_init_$Create$, AbstractMutableMap, [KtMap]);
164
- initMetadataForClass(HashMapKeys, 'HashMapKeys', VOID, AbstractMutableSet, [Collection, MutableIterable, KtSet]);
164
+ initMetadataForClass(HashMapKeys, 'HashMapKeys', VOID, AbstractMutableSet, [KtSet, MutableIterable, Collection]);
165
165
  initMetadataForClass(HashMapValues, 'HashMapValues', VOID, AbstractMutableCollection, [Collection, MutableIterable]);
166
- initMetadataForClass(HashMapEntrySetBase, 'HashMapEntrySetBase', VOID, AbstractMutableSet, [Collection, MutableIterable, KtSet]);
166
+ initMetadataForClass(HashMapEntrySetBase, 'HashMapEntrySetBase', VOID, AbstractMutableSet, [KtSet, MutableIterable, Collection]);
167
167
  initMetadataForClass(HashMapEntrySet, 'HashMapEntrySet', VOID, HashMapEntrySetBase);
168
168
  initMetadataForClass(HashMapKeysDefault$iterator$1);
169
169
  initMetadataForClass(HashMapKeysDefault, 'HashMapKeysDefault', VOID, AbstractMutableSet);
170
170
  initMetadataForClass(HashMapValuesDefault$iterator$1);
171
171
  initMetadataForClass(HashMapValuesDefault, 'HashMapValuesDefault', VOID, AbstractMutableCollection);
172
- initMetadataForClass(HashSet, 'HashSet', HashSet_init_$Create$, AbstractMutableSet, [Collection, MutableIterable, KtSet]);
172
+ initMetadataForClass(HashSet, 'HashSet', HashSet_init_$Create$, AbstractMutableSet, [KtSet, MutableIterable, Collection]);
173
173
  initMetadataForCompanion(Companion_3);
174
174
  initMetadataForClass(Itr, 'Itr');
175
175
  initMetadataForClass(KeysItr, 'KeysItr', VOID, Itr);
@@ -214,7 +214,7 @@ if (typeof String.prototype.startsWith === 'undefined') {
214
214
  initMetadataForInterface(InternalMap, 'InternalMap');
215
215
  initMetadataForClass(InternalHashMap, 'InternalHashMap', InternalHashMap_init_$Create$, VOID, [InternalMap]);
216
216
  initMetadataForClass(LinkedHashMap, 'LinkedHashMap', LinkedHashMap_init_$Create$, HashMap, [KtMap]);
217
- initMetadataForClass(LinkedHashSet, 'LinkedHashSet', LinkedHashSet_init_$Create$, HashSet, [Collection, MutableIterable, KtSet]);
217
+ initMetadataForClass(LinkedHashSet, 'LinkedHashSet', LinkedHashSet_init_$Create$, HashSet, [KtSet, MutableIterable, Collection]);
218
218
  initMetadataForInterface(Continuation, 'Continuation');
219
219
  initMetadataForClass(InterceptedCoroutine, 'InterceptedCoroutine', VOID, VOID, [Continuation]);
220
220
  initMetadataForClass(CoroutineImpl, 'CoroutineImpl', VOID, InterceptedCoroutine, [Continuation]);
@@ -107,8 +107,8 @@
107
107
  var stackTraceToString = kotlin_kotlin.$_$.sa;
108
108
  var removeFirstOrNull = kotlin_kotlin.$_$.x3;
109
109
  var KtList = kotlin_kotlin.$_$.o2;
110
- var Collection = kotlin_kotlin.$_$.n2;
111
110
  var MutableIterable = kotlin_kotlin.$_$.q2;
111
+ var Collection = kotlin_kotlin.$_$.n2;
112
112
  //endregion
113
113
  //region block: pre-declaration
114
114
  initMetadataForInterface(ParentJob, 'ParentJob', VOID, VOID, [Element], [0]);
@@ -184,7 +184,7 @@
184
184
  initMetadataForClass(SemaphoreSegment, 'SemaphoreSegment', VOID, Segment);
185
185
  initMetadataForClass(SetTimeoutBasedDispatcher, 'SetTimeoutBasedDispatcher', VOID, CoroutineDispatcher, VOID, [1]);
186
186
  initMetadataForObject(NodeDispatcher, 'NodeDispatcher', VOID, SetTimeoutBasedDispatcher, VOID, [1]);
187
- initMetadataForClass(MessageQueue, 'MessageQueue', VOID, VOID, [KtList, Collection, MutableIterable]);
187
+ initMetadataForClass(MessageQueue, 'MessageQueue', VOID, VOID, [KtList, MutableIterable, Collection]);
188
188
  initMetadataForClass(ScheduledMessageQueue, 'ScheduledMessageQueue', VOID, MessageQueue);
189
189
  initMetadataForClass(WindowMessageQueue, 'WindowMessageQueue', VOID, MessageQueue);
190
190
  initMetadataForObject(Dispatchers, 'Dispatchers');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glandais/vcyclist-engine",
3
- "version": "4.2.1",
3
+ "version": "4.3.0",
4
4
  "main": "vcyclist-engine.js",
5
5
  "types": "vcyclist-engine.d.ts",
6
6
  "devDependencies": {