lecodes-assets 0.2.0 → 2.4.10

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.
Files changed (128) hide show
  1. package/README.md +738 -449
  2. package/dist/cli.js +19760 -8612
  3. package/dist/index.js +19334 -8167
  4. package/package.json +15 -3
  5. package/src/bvh/fromDocument.ts +136 -0
  6. package/src/bvh/toDocument.ts +202 -0
  7. package/src/cli-kit/command.ts +100 -0
  8. package/src/cli-kit/help.ts +72 -0
  9. package/src/cli-kit/index.ts +4 -0
  10. package/src/cli-kit/parse.ts +72 -0
  11. package/src/cli-kit/run.ts +52 -0
  12. package/src/cli.ts +24 -0
  13. package/src/commands/avatar.ts +77 -0
  14. package/src/commands/bvh.ts +26 -0
  15. package/src/commands/convert.ts +156 -0
  16. package/src/commands/doctor.ts +67 -0
  17. package/src/commands/geometry.ts +45 -0
  18. package/src/commands/index.ts +24 -0
  19. package/src/commands/marks.ts +38 -0
  20. package/src/commands/mirror.ts +35 -0
  21. package/src/commands/probe.ts +43 -0
  22. package/src/commands/retarget/donor.ts +63 -0
  23. package/src/commands/retarget/index.ts +169 -0
  24. package/src/commands/retarget/map.ts +81 -0
  25. package/src/commands/retarget/report.ts +77 -0
  26. package/src/commands/scene.ts +103 -0
  27. package/src/commands/shared.ts +121 -0
  28. package/src/commands/sky.ts +35 -0
  29. package/src/commands/terrainPack.ts +40 -0
  30. package/src/commands/unpack.ts +43 -0
  31. package/src/commands/unreal.ts +109 -0
  32. package/src/commands/unwrap.ts +56 -0
  33. package/src/doctor/cardTrim.ts +286 -0
  34. package/src/doctor/doctor.ts +305 -0
  35. package/src/doctor/lod.ts +177 -0
  36. package/src/doctor/merge/accessors.ts +30 -0
  37. package/src/doctor/merge/atlasLayout.ts +94 -0
  38. package/src/doctor/merge/atlasPixels.ts +114 -0
  39. package/src/doctor/merge/bindSpace.ts +59 -0
  40. package/src/doctor/merge/classify.ts +136 -0
  41. package/src/doctor/merge/geometry.ts +167 -0
  42. package/src/doctor/merge/index.ts +98 -0
  43. package/src/doctor/merge/matrix.ts +60 -0
  44. package/src/doctor/merge/plan.ts +88 -0
  45. package/src/doctor/merge/textures.ts +60 -0
  46. package/src/doctor/merge/types.ts +77 -0
  47. package/src/doctor/normalizeScale.ts +220 -0
  48. package/src/doctor/reduceGlbBones.ts +218 -0
  49. package/src/doctor/shading.ts +95 -0
  50. package/src/doctor/transcodeKtx2.ts +275 -0
  51. package/src/doctor/transcodeTextures.ts +101 -0
  52. package/src/doctor/validateGlb.ts +264 -0
  53. package/src/doctor/vertexAo.ts +321 -0
  54. package/src/fbx/convert.ts +327 -0
  55. package/src/fbx/read.ts +111 -0
  56. package/src/fbx/toDocument.ts +227 -0
  57. package/src/filmstrip.ts +263 -0
  58. package/src/geometry.ts +222 -0
  59. package/src/index.ts +19 -0
  60. package/src/mergeSkins.ts +117 -0
  61. package/src/probe.ts +167 -0
  62. package/src/props.ts +137 -0
  63. package/src/retarget/automap.ts +276 -0
  64. package/src/retarget/ghost.ts +23 -0
  65. package/src/retarget/heading.ts +238 -0
  66. package/src/retarget/humanoid.ts +572 -0
  67. package/src/retarget/inPlace.ts +130 -0
  68. package/src/retarget/marks.ts +581 -0
  69. package/src/retarget/mirror.ts +0 -0
  70. package/src/retarget/retarget.ts +2375 -0
  71. package/src/retarget/rig.ts +545 -0
  72. package/src/retarget/unityAnim.ts +171 -0
  73. package/src/retarget/writeMarks.ts +134 -0
  74. package/src/sky/analyze.ts +136 -0
  75. package/src/sky/cmgen.ts +71 -0
  76. package/src/sky/index.ts +200 -0
  77. package/src/terrainPack.ts +208 -0
  78. package/src/textures/resolveImage.ts +202 -0
  79. package/src/unity/colliderFit.ts +287 -0
  80. package/src/unity/fbxModel.ts +194 -0
  81. package/src/unity/foliage.ts +63 -0
  82. package/src/unity/gameObjects.ts +81 -0
  83. package/src/unity/guid.ts +104 -0
  84. package/src/unity/image.ts +58 -0
  85. package/src/unity/kindGlb.ts +364 -0
  86. package/src/unity/lecodesLevel.ts +381 -0
  87. package/src/unity/levelTerrain.ts +116 -0
  88. package/src/unity/matFile.ts +179 -0
  89. package/src/unity/materialSlots.ts +53 -0
  90. package/src/unity/materialWriter.ts +306 -0
  91. package/src/unity/materials.ts +198 -0
  92. package/src/unity/math.ts +82 -0
  93. package/src/unity/png.ts +75 -0
  94. package/src/unity/prefabTree.ts +285 -0
  95. package/src/unity/prefabs.ts +54 -0
  96. package/src/unity/propertyPath.ts +43 -0
  97. package/src/unity/scene.ts +384 -0
  98. package/src/unity/serialized.ts +242 -0
  99. package/src/unity/terrain.ts +221 -0
  100. package/src/unity/triBudget.ts +77 -0
  101. package/src/unity/triplanar.ts +122 -0
  102. package/src/unity/unityMesh.ts +151 -0
  103. package/src/unity/unpack.ts +149 -0
  104. package/src/unity/yaml.ts +131 -0
  105. package/src/unreal/anim.ts +284 -0
  106. package/src/unreal/clipSource.ts +25 -0
  107. package/src/unreal/convert.ts +67 -0
  108. package/src/unreal/convertAsset.ts +81 -0
  109. package/src/unreal/index.ts +115 -0
  110. package/src/unreal/list.ts +113 -0
  111. package/src/unreal/materialGraph.ts +224 -0
  112. package/src/unreal/materials.ts +399 -0
  113. package/src/unreal/mesh.ts +205 -0
  114. package/src/unreal/oodle.ts +153 -0
  115. package/src/unreal/package.ts +677 -0
  116. package/src/unreal/probe.ts +144 -0
  117. package/src/unreal/resolve.ts +53 -0
  118. package/src/unreal/rig.ts +149 -0
  119. package/src/unreal/sequencer.ts +193 -0
  120. package/src/unreal/skeleton.ts +74 -0
  121. package/src/unreal/sound.ts +128 -0
  122. package/src/unreal/texture.ts +269 -0
  123. package/src/unreal/toDocument.ts +367 -0
  124. package/src/unreal/versions.ts +108 -0
  125. package/src/unwrap/index.ts +167 -0
  126. package/src/unwrap/lightmapUv.ts +761 -0
  127. package/src/wasm/fbxReader.js +14 -0
  128. package/src/wasm/lightmapUv.js +14 -0
@@ -0,0 +1,238 @@
1
+ // The clip's HEADING curve — where the character FACES, for the node's turn under root rotation and
2
+ // for the game's course. Degrees, + = toward +X (the character's left when she faces +Z), 0 = the
3
+ // clip's own +Z at frame 0.
4
+ //
5
+ // Neither the body nor the travel alone will do (worked out with the owner, 2026-09-03):
6
+ // - a pelvis carries the STANCE: Kachujin's idle stands 43° off the direction her straight start
7
+ // runs, so every start's pelvis turns 46–49° more than the heading, a stop's pelvis twists back
8
+ // by as much while she is no longer turning, and a right-45 start's pelvis barely moves at all;
9
+ // - the travel cannot tell which way ROUND the body turned before it moved, and it cannot see a
10
+ // strafe (a face held forward while the body goes sideways).
11
+ // So: while the body MOVES, the heading is the smoothed direction of the root's travel, unwrapped
12
+ // to the nearest turn from the previous frame; while it STANDS after having moved, the heading holds;
13
+ // in the LEADING standing phase (a start that turns on the spot) it goes from 0 to the first moving
14
+ // heading following the PELVIS' turn — its sense and its shape (a body that spun the long way round
15
+ // gets its −270, not a +90) — or a smooth ramp when the pelvis did not turn (the right-45 case). A
16
+ // SLOW window in the middle (a pivot the body runs out of again) follows the pelvis too, and the
17
+ // travel takes over in proportion to the frame's moving weight, so a 180 pivot turns the heading
18
+ // through the pivot instead of stepping it the frame the body moves again. A clip tagged `strafe`
19
+ // keeps 0 throughout: the face stays where the animator put it.
20
+
21
+ export type HeadingCurve = { fps: number, deg: number[] }
22
+
23
+ export interface HeadingOptions {
24
+ /** the face stays forward whatever the travel does (a strafe, a backpedal) */
25
+ strafe?: boolean
26
+ /** speed below which a frame says nothing about the heading (m/s, default 0.6 — a turn on the spot sways the
27
+ * root at 0.4–0.8 m/s in every direction before the body goes) */
28
+ still?: number
29
+ /** speed at which a frame counts fully (m/s, default 1.5) */
30
+ moving?: number
31
+ /** the Gaussian's sigma on the travel direction, seconds (default 0.15 — under half a running step) */
32
+ sigma?: number
33
+ /** the pelvis must have turned this much (degrees) by the first moving frame for its curve to shape the
34
+ * leading phase (default 30) */
35
+ minTurn?: number
36
+ /** through a PIVOT (a slow window the body runs out of again) the heading leaves the travel's line for the
37
+ * pelvis' as the speed drops from this (m/s, default 3.5 — a run's pace) to `still`, and comes back the
38
+ * same way; a final stop keeps the travel's line */
39
+ pivotSpeed?: number
40
+ /** the heading the clip must END at (degrees, + = left): a generated STOP THAT TURNS ends in an idle pose pinned
41
+ * facing the turn, while the pelvis rule alone ends at the turn plus the idle-vs-run stance twist (~40° on
42
+ * Kachujin). The difference is blended in from the frame the body first stands after its last fast frame to
43
+ * the last frame — the head (the turn's own frames) is left as the turn bakes it. */
44
+ endAt?: number
45
+ /** the clip is a LOOP (its last frame is its first, a cycle on): the travel direction is smoothed PERIODICALLY —
46
+ * the sequence continued past either end by the loop's own turn — so the curve is the clean ramp the motion is.
47
+ * Clamped at the ends like a one-shot, a 0.6 s arc loop lost ~7° at each end of its 38° (the Gaussian's
48
+ * truncated half averages the inside only) and the engine turned the node by 23° a pass, the travel by 38:
49
+ * she crabbed 15° a cycle (ArcL4 on Kachujin, 2026-09-05). No leading / tail / pinned-end rules: a loop has
50
+ * no standing phase. */
51
+ loop?: boolean
52
+ }
53
+
54
+ const wrap180 = (d: number): number => { while (d > 180) d -= 360; while (d < -180) d += 360; return d }
55
+
56
+ /** `root`: the root's position per frame ([x, y, z], metres); `pelvisYaw`: the pelvis' world yaw per
57
+ * frame, RADIANS (atan2 of its forward's x over z); `dt` the frame step (seconds). */
58
+ export const headingCurve = (root: ArrayLike<number>[], pelvisYaw: ArrayLike<number>, dt: number, opts: HeadingOptions = {}): HeadingCurve => {
59
+ const n = Math.min(root.length, pelvisYaw.length)
60
+ const fps = dt > 0 ? 1 / dt : 30
61
+ if (n < 2 || dt <= 0 || opts.strafe) return { fps, deg: new Array(Math.max(1, n)).fill(0) }
62
+ const still = opts.still ?? 0.6, moving = opts.moving ?? 1.5, sigma = opts.sigma ?? 0.15, minTurn = opts.minTurn ?? 30, pivotSpeed = opts.pivotSpeed ?? 3.5
63
+ // per frame: the horizontal velocity (central difference) as a unit vector and its moving weight
64
+ const ux = new Float64Array(n), uz = new Float64Array(n), w = new Float64Array(n), wp = new Float64Array(n)
65
+ for (let k = 0; k < n; k++) {
66
+ const a = Math.max(0, k - 1), b = Math.min(n - 1, k + 1)
67
+ const vx = (root[b]![0]! - root[a]![0]!) / ((b - a) * dt), vz = (root[b]![2]! - root[a]![2]!) / ((b - a) * dt)
68
+ const s = Math.hypot(vx, vz)
69
+ w[k] = Math.max(0, Math.min(1, (s - still) / Math.max(1e-6, moving - still)))
70
+ wp[k] = Math.max(0, Math.min(1, (s - still) / Math.max(1e-6, pivotSpeed - still)))
71
+ if (s > 1e-6) { ux[k] = vx / s; uz[k] = vz / s }
72
+ }
73
+ if (opts.loop) return loopHeading(ux, uz, w, n, dt, sigma, fps)
74
+ // the pelvis yaw since frame 0, unwrapped (degrees)
75
+ const pelvis = new Float64Array(n)
76
+ for (let k = 1; k < n; k++) pelvis[k] = pelvis[k - 1]! + wrap180((pelvisYaw[k]! - pelvisYaw[k - 1]!) * 180 / Math.PI)
77
+ // the travel direction, Gaussian-smoothed over the weighted unit vectors; hold while standing
78
+ const r = Math.max(1, Math.round(3 * sigma / dt))
79
+ const kern: number[] = []
80
+ for (let i = -r; i <= r; i++) kern.push(Math.exp(-0.5 * (i * dt / sigma) ** 2))
81
+ const deg: number[] = new Array(n).fill(0)
82
+ let prev = 0, prevK = -1, off = 0
83
+ let first = w.findIndex((v) => v >= 0.95)
84
+ if (first < 0) first = w.findIndex((v) => v >= 0.05)
85
+ // a PIVOT: a stretch between two FAST frames (at `pivotSpeed`, a run's pace — the braking into the pivot
86
+ // starts from there) that dips to standing (w < 0.05) — the run-to-run 180 all but stops for ~0.2 s and
87
+ // runs back. Through it the heading follows the PELVIS, graded by speed; a start's leading phase and a
88
+ // stop's braking (no fast frame on the other side) keep their own rules
89
+ const pivot = new Uint8Array(n)
90
+ let lastFast = -1
91
+ for (let k = 0; k < n; k++) {
92
+ if (wp[k]! < 0.95) continue
93
+ if (lastFast >= 0 && k - lastFast > 1) { let dips = false; for (let j = lastFast + 1; j < k; j++) if (w[j]! < 0.05) { dips = true; break }; if (dips) for (let j = lastFast + 1; j < k; j++) pivot[j] = 1 }
94
+ lastFast = k
95
+ }
96
+ // …and a STOP THAT TURNS (`endAt` given — the caller knows the clip ends facing a turn): the stretch after the
97
+ // last fast frame when the clip ends standing and the pelvis went on turning through it (≥ minTurn) — a
98
+ // run-to-run turn's stop, cut in the pivot. No fast frame on the other side, but the heading must leave the
99
+ // travel's line for the pelvis' the same graded way, or it steps 180° the frame the body moves again (the step
100
+ // back into the stance). Gated on `endAt` on purpose: a STRAIGHT stop's pelvis also turns ~40° into the idle's
101
+ // stance, and this rule would bake that twist into its course (it did — every straight stop drifted 40°).
102
+ // (…and only when the END the caller asks for actually lies a turn away from the travel's line at the last fast
103
+ // frame: `endAt` is given for EVERY generated clip since 2026-09-05, a straight stop's included, and its pelvis
104
+ // turns ~40° into the stance as well — that alone made a straight stop's tail follow the pelvis to 54° and back)
105
+ const tail = new Uint8Array(n)
106
+ const travelAtFast = lastFast >= 0 ? Math.atan2(ux[lastFast]!, uz[lastFast]!) * 180 / Math.PI : 0
107
+ const endTurns = opts.endAt !== undefined && Math.abs(wrap180(opts.endAt - travelAtFast)) >= minTurn
108
+ if (endTurns && lastFast >= 0 && lastFast < n - 1 && w[n - 1]! < 0.05 && Math.abs(pelvis[n - 1]! - pelvis[lastFast]!) >= minTurn) for (let j = lastFast + 1; j < n; j++) pivot[j] = tail[j] = 1
109
+ // A FINAL STOP'S TAIL: after the last frame of confident motion (w ≥ 0.95, `moving`) with no such frame after it,
110
+ // the body only creeps and steps into its stance at 0.6–2 m/s in whatever direction the settle takes — that is
111
+ // not travel, and read as travel it turned a straight stop's course 5.5° (KM_STM on Kachujin, 2026-09-05: the
112
+ // node turned while she stood, the feet visibly rotating, and every following start began 5.5° off). The tail
113
+ // HOLDS the heading of the last confident frame; `endAt` (below) then brings it to where the clip must end.
114
+ // A pivot-tail (a turn stop cut in its pivot, `tail[]`) keeps its pelvis-graded rule.
115
+ let lastMoving = -1
116
+ for (let k = 0; k < n; k++) if (w[k]! >= 0.95) lastMoving = k
117
+ for (let k = 0; k < n; k++) {
118
+ if (w[k]! < 0.05) { deg[k] = pivot[k] && prevK >= 0 ? pelvis[k]! + off : prev; prev = deg[k]!; continue }
119
+ if (lastMoving >= 0 && k > lastMoving && !pivot[k]) { deg[k] = prev; continue }
120
+ let sx = 0, sz = 0, sw = 0
121
+ for (let i = -r; i <= r; i++) {
122
+ const j = k + i
123
+ if (j < 0 || j >= n) continue
124
+ const c = kern[i + r]! * w[j]!
125
+ sx += c * ux[j]!; sz += c * uz[j]!; sw += c
126
+ }
127
+ let h = prev
128
+ if (sw > 0.05 && Math.hypot(sx, sz) > 1e-6) {
129
+ const a = Math.atan2(sx, sz) * 180 / Math.PI
130
+ // the first moving frame unwraps NEAREST TO THE PELVIS' TURN (the sense the body chose); after
131
+ // that, nearest to the PELVIS' CURRENT YAW plus the stance offset seen at that first frame — a
132
+ // run that turns about (AA_Running_Turn_180: the travel flips within a frame or two while the
133
+ // pelvis turns through 180 over the whole clip) must unwrap the way the body went, and neither
134
+ // the previous heading nor the pelvis' turn since the previous frame can tell (it baked +190
135
+ // for a pelvis that turned -180). The offset absorbs the stance twist (up to ~50 deg), well
136
+ // under the 180 that separates the two candidates.
137
+ const ref = k === first && Math.abs(pelvis[first]!) >= minTurn ? pelvis[first]!
138
+ : prevK >= 0 ? pelvis[k]! + off : prev
139
+ // …and through a PIVOT the travel's say is the speed's: the heading leaves the travel's line for the
140
+ // pelvis' as the body brakes into the pivot and returns the same way, not by a 180 step the frame
141
+ // the body moves again (the node snapped 180 in one frame there and the body visibly shifted — the
142
+ // run-to-run 180; the pelvis leads the travel by ~60 deg into a pivot, so any switch between the two
143
+ // references is a step unless it is graded by speed). A final stop keeps the travel's line.
144
+ // (in a stop's tail the body creeps and steps back: its smoothed travel is the mean of directions that
145
+ // disagree — the travel's say is scaled by that mean's length, its confidence, so a creeping body's
146
+ // garbage direction cannot nick the curve; the run-to-run turns keep their tuned shape untouched)
147
+ const conf = tail[k] ? Math.min(1, Math.hypot(sx, sz) / sw) : 1
148
+ h = ref + (pivot[k] && k !== first ? wp[k]! * conf : 1) * wrap180(a - ref)
149
+ if (k === first) off = h - pelvis[first]!
150
+ }
151
+ deg[k] = h
152
+ prev = h
153
+ prevK = k
154
+ }
155
+ // the LEADING standing phase: from 0 to the first moving heading — shaped by the pelvis' own turn when
156
+ // it turned (a turn on the spot: the sense and the pace are the body's), a smooth ramp when it did not
157
+ if (first > 1) {
158
+ const target = deg[first]!
159
+ const bodyTurn = pelvis[first]!
160
+ for (let k = 0; k < first; k++) {
161
+ let e: number
162
+ if (Math.abs(bodyTurn) >= minTurn) e = Math.max(0, Math.min(1, pelvis[k]! / bodyTurn))
163
+ else { const u = k / first; e = u * u * (3 - 2 * u) }
164
+ deg[k] = target * e
165
+ }
166
+ }
167
+ // the END pINNED (`endAt`): the difference to it blended in from the first standing frame after the last fast
168
+ // frame (the pivot's bottom — the turn's own frames before it stay as the turn bakes them) to the last frame
169
+ if (opts.endAt !== undefined && n > 1) {
170
+ let k0 = lastFast >= 0 ? lastFast + 1 : 0
171
+ while (k0 < n - 1 && w[k0]! >= 0.05) k0++
172
+ // a clip that ENDS MOVING (a start, a run turn) has no standing tail to blend over: pinned on its last frame
173
+ // alone the difference was a 1–3° step in one frame, right at the seam into the run. Spread it over the
174
+ // last 0.5 s of motion instead.
175
+ if (k0 >= n - 1) k0 = Math.max(first > 0 ? first : 0, n - 1 - Math.round(0.5 / dt))
176
+ // …and the end is taken NEAREST ON THE CIRCLE: a 135 that went the long way round (−225) asked to end at
177
+ // +135 must not be handed 360° of extra turn over its tail
178
+ const c = wrap180(opts.endAt - deg[n - 1]!)
179
+ const span = Math.max(1, n - 1 - k0)
180
+ for (let k = k0; k < n; k++) { const u = Math.min(1, (k - k0) / span); deg[k] = deg[k]! + c * u * u * (3 - 2 * u) }
181
+ }
182
+ // a clip that does not turn (a run loop, an idle, a stop, a straight start) is pinned to 0 outright: its
183
+ // wobble is the gait's sway, and integrated by root rotation it drifted the node +-1..3 deg per run
184
+ const maxAbs = deg.reduce((m, v) => Math.max(m, Math.abs(v!)), 0)
185
+ if (Math.abs(deg[n - 1]!) < 5 && maxAbs < 8) deg.fill(0)
186
+ for (let k = 0; k < n; k++) deg[k] = Math.round(deg[k]! * 100) / 100
187
+ return { fps, deg }
188
+ }
189
+
190
+ /** A LOOP's heading: the unwrapped travel angle, continued past either end by the loop's turn (the period is
191
+ * n − 1 frames — the last frame repeats the first), Gaussian-smoothed over that periodic sequence. A loop that
192
+ * does not turn (a straight run, |turn| < 5°) is pinned to 0 like a one-shot: its wobble is the gait's sway. */
193
+ const loopHeading = (ux: Float64Array, uz: Float64Array, w: Float64Array, n: number, dt: number, sigma: number, fps: number): HeadingCurve => {
194
+ const period = n - 1
195
+ // the raw angle per frame, unwrapped from the previous (a still frame keeps the previous angle)
196
+ const ang = new Float64Array(n)
197
+ for (let k = 0; k < n; k++) {
198
+ const has = Math.hypot(ux[k]!, uz[k]!) > 1e-6
199
+ const a = has ? Math.atan2(ux[k]!, uz[k]!) * 180 / Math.PI : 0
200
+ ang[k] = k === 0 ? a : ang[k - 1]! + (has ? wrap180(a - ang[k - 1]!) : 0)
201
+ }
202
+ // the loop's turn: the angle a cycle on is the angle now plus this (the last frame IS the first, so its
203
+ // direction is the first's turned by the loop; the raw end-to-end difference says how much)
204
+ const turn = ang[n - 1]! - ang[0]!
205
+ if (Math.abs(turn) < 5) return { fps, deg: new Array(n).fill(0) }
206
+ const at = (j: number): { a: number, w: number } => {
207
+ let cyc = 0
208
+ while (j < 0) { j += period; cyc-- }
209
+ while (j >= period) { j -= period; cyc++ }
210
+ return { a: ang[j]! + cyc * turn, w: w[j]! }
211
+ }
212
+ const r = Math.max(1, Math.round(3 * sigma / dt))
213
+ const deg: number[] = new Array(n).fill(0)
214
+ // (the sequence is unwrapped and continued continuously, so this is a plain weighted mean of angles)
215
+ for (let k = 0; k < n; k++) {
216
+ let s = 0, sw = 0
217
+ for (let i = -r; i <= r; i++) {
218
+ const c = Math.exp(-0.5 * (i * dt / sigma) ** 2)
219
+ const p = at(k + i)
220
+ s += c * p.w * p.a; sw += c * p.w
221
+ }
222
+ deg[k] = sw > 1e-6 ? s / sw : ang[k]!
223
+ }
224
+ // the last frame is the first, a turn on — exactly, so the engine's seam (heading(end) − heading(0)) is the turn
225
+ deg[n - 1] = deg[0]! + turn
226
+ for (let k = 0; k < n; k++) deg[k] = Math.round(deg[k]! * 100) / 100
227
+ return { fps, deg }
228
+ }
229
+
230
+ /** The curve's value at `t` (seconds), linearly interpolated; past the end, the last value. */
231
+ export const headingAt = (h: HeadingCurve, t: number): number => {
232
+ const f = t * h.fps
233
+ const i = Math.floor(f)
234
+ if (i < 0) return h.deg[0] ?? 0
235
+ if (i >= h.deg.length - 1) return h.deg[h.deg.length - 1] ?? 0
236
+ const u = f - i
237
+ return h.deg[i]! + (h.deg[i + 1]! - h.deg[i]!) * u
238
+ }